CLAUDE.md update

This commit is contained in:
Jakub Zych
2026-04-30 00:15:23 +02:00
parent 579e4984c2
commit c8f9c11c93
6 changed files with 300 additions and 277 deletions

321
CLAUDE.md
View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Current state (read this first) ## Current state (read this first)
This is the active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), six custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target). Bring up a new rig by running `bash scripts/deploy-rig.sh` after `git clone`. All `.sh` automation lives in `scripts/`; everything Python lives where it serves (`bridges/`, `twitch-bot/`). Active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), six custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target).
## What this directory is ## What this directory is
@@ -12,9 +12,7 @@ Active OBS Studio user config for the **Flatpak** install (`com.obsproject.Studi
**Why `~/.config/` and not `~/.var/app/com.obsproject.Studio/config/obs-studio/`?** The Flatpak ships with `filesystems=host` (full host access, see `flatpak info --show-permissions com.obsproject.Studio`). With that permission, Flatpak does **not** indirect XDG paths into the per-app private dir — OBS sees the real `$HOME/.config/` and uses `~/.config/obs-studio/` directly, like a native install would. **Why `~/.config/` and not `~/.var/app/com.obsproject.Studio/config/obs-studio/`?** The Flatpak ships with `filesystems=host` (full host access, see `flatpak info --show-permissions com.obsproject.Studio`). With that permission, Flatpak does **not** indirect XDG paths into the per-app private dir — OBS sees the real `$HOME/.config/` and uses `~/.config/obs-studio/` directly, like a native install would.
A ghost copy lives at `~/.var/app/com.obsproject.Studio/config/obs-studio/` — that's the path Flatpak *would* use if `filesystems=host` weren't set. **OBS does not read or write there.** Some files (older CLAUDE.md, an aborted 2026-04 source-id migration, a `landing/` asset duplicate) accumulated there from work that targeted the wrong path. Treat it as orphaned; don't edit it expecting OBS to pick changes up. A ghost copy lives at `~/.var/app/com.obsproject.Studio/config/obs-studio/` — that's the path Flatpak *would* use if `filesystems=host` weren't set. **OBS does not read or write there.** Some files accumulated there from work that targeted the wrong path. Treat it as orphaned; don't edit it expecting OBS to pick changes up.
The user works here to (a) edit scene collections directly as JSON, (b) build/iterate stream overlays as local HTML/CSS/JS rendered via the browser source, and (c) maintain the audio + chat-bot infrastructure that surrounds OBS.
## How OBS is launched ## How OBS is launched
@@ -22,207 +20,50 @@ The user works here to (a) edit scene collections directly as JSON, (b) build/it
flatpak run com.obsproject.Studio flatpak run com.obsproject.Studio
``` ```
Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `pacman` but should be considered dead`obs-studio` and `obs-pipewire-audio-capture-bin{,-debug}` are pending removal. Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `pacman` but should be considered dead.
## Layout — what lives where ## Layout — what lives where
**OBS-owned (don't edit while OBS is running — see "OBS overwrites on exit"):** **OBS-owned (don't edit while OBS is running — see "OBS overwrites on exit" below):**
- `user.ini` / `global.ini` — selects active scene collection and profile, plus window state. `user.ini` is the live one (currently collection `Default Stream HUD`, profile `ophi118`). Don't touch geometry/dock-state lines (huge base64 blobs OBS owns). - `user.ini` / `global.ini` — selects active scene collection and profile, plus window state. `user.ini` is the live one (currently collection `Default Stream HUD`, profile `ophi118`).
- `basic/scenes/<name>.json`**the scene collection**. One file = one full set of scenes + sources + transitions + audio routing. Active: `Default_Stream_HUD.json` (display name `Default Stream HUD`). Scenes inside: `Landing`, `Project Loading`, `Game`, `Desktop`, `Music Box`, `Good Bye` (Ctrl+M switches to Music Box; Ctrl+, to Desktop; Ctrl+. to Game). - `basic/scenes/<name>.json` — the scene collection. Active: `Default_Stream_HUD.json`. Scenes inside: `Landing`, `Project Loading`, `Game`, `Desktop`, `Music Box`, `Good Bye` (Ctrl+M / Ctrl+, / Ctrl+. switch among Music Box / Desktop / Game).
- `basic/profiles/<name>/` — encoder, streaming service, recording settings (separate from scenes). Active profile: `ophi118`. - `basic/profiles/<name>/` — encoder, streaming service, recording settings. Active: `ophi118`.
- `plugin_config/<plugin-id>/` — per-plugin settings. `obs-websocket/config.json` holds the WebSocket password — secret. - `plugin_config/<plugin-id>/` — per-plugin settings. `obs-websocket/config.json` holds the WebSocket password — secret.
- `plugin_manager/modules.json` — third-party plugins OBS loads. Currently lists only `linux-pipewire-audio` (the bundled-into-flatpak version, same source ids as the AUR plugin it replaced). - `plugin_manager/modules.json` — third-party plugins OBS loads.
- `logs/YYYY-MM-DD HH-MM-SS.txt` — one per launch. Most recent is the source of truth for "why did source X fail to load" or "what plugin crashed". `MaxLogs=10`. - `logs/`, `profiler_data/`, `.sentinel/` — runtime state.
- `profiler_data/` — perf traces. Ignore unless profiling.
- `.sentinel/run_<uuid>` — running-instance lockfile; safe to delete only if no OBS process is alive.
**Custom overlays (browser sources rendering local HTML):** **Custom overlays (browser sources rendering local HTML — safe to edit while OBS is up):**
- `landing/index.html` — Fallout-style "PLEASE STAND BY" overlay. Self-contained: HUD + cyan ring + accent triangle + footer + animated static/scanlines/vignette. Reads `landing/telemetry.js` for the rig and hardware info shown on screen. Top-left HUD identifier is `OPHI-118 // [<RIG>]` where the rig portion does a cyberpunk decode/scramble reveal on first paint and re-glitches every ~9s. - `landing/` — Fallout-style "PLEASE STAND BY" overlay + telemetry + 12 s static-hum loop.
- `landing/static-hum.wav` — 12-second seamless CRT static loop, generated via `ffmpeg -filter_complex` (white+brown noise + 60Hz hum, filtered + crossfaded). Loaded by an `ffmpeg_source`. - `loading/` — terminal-style Project Loading overlay + per-session manifest + flat playlist index + legacy `cmd.js`.
- `landing/telemetry.json` / `telemetry.js` — hardware + OBS + rig snapshot. JSON is hand-editable (or interactively curated via `scripts/telemetry.sh --collect`), JS is the auto-generated `<script>`-injectable wrapper. The JSON's `rig` field is consumed by the Loading overlay too. - `game/index.html` — Game scene HUD (TRANSMISSION ID, signal, clock, camera frame, status bar). Live-syncs camera transform via OBS WS.
- `loading/index.html`terminal-style Project Loading overlay. Reads `loading/loading.json` (manifest), `loading/playlist.json` (track count), `landing/telemetry.js` (rig name), and listens on OBS WS for `mpd:state` updates from the bridge. - `desktop/index.html`Desktop scene HUD, forked from Game (no game-name slot, no music widget — that lives below the camera as a separate source).
- `loading/loading.json` / `loading.js` — per-session manifest (game, subtitle, countdown, camera/mic flags). Written by `scripts/loading.sh`. - `goodbye/index.html` — sign-off overlay.
- `loading/playlist.json` / `playlist.js` — flat library index built by `scripts/playlist.sh`. - `music-box/index.html` — full-screen Music Box terminal (scrolling `[audio]` log + pinned 3-up next + chat help strip).
- `loading/cmd.js` — legacy daemon command file (`skip`/`prev`/`pause`/`resume`), polled at 250 ms by `music/index.html`. Written by `scripts/playlist.sh skip` etc. Functionally superseded by direct MPD control (see Twitch bot) but still wired for browser-side commands. - `music-box/widget.html` — compact 549×880 sidebar widget for the Desktop scene. **Authored at native pixel size** (`bounds_type: 0`, no stretch).
- `game/index.html`Game scene HUD. TRANSMISSION ID + SIGNAL + UTC clock (top), game/subtitle (top center), camera frame (right), status bar with mic + now-playing (bottom). Live-syncs the camera-frame transform via OBS WS (`SceneItemTransformChanged`). See "Game scene HUD — browser-resolution constraint" below. - `music/index.html`legacy "Music Daemon" browser source.
- `desktop/index.html` — Desktop scene HUD. Forked from `game/index.html`: same top HUD + camera-frame OBS-sync, but no game-name slot (status bar reads "DESKTOP"), no manifest dependence, no status-bar music widget (now-playing lives in the dedicated `music-box/widget.html` source below the camera). Defaults to `?scene=Desktop&camera=Camera`. - `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client. `vendor/obs-config.js` is the auto-generated WS connection config (gitignored).
- `goodbye/index.html` — sign-off overlay (Good Bye scene). Same terminal style as Loading; subscribes to `mpd:state`.
- `music-box/index.html` — Music Box scene full-screen overlay. Terminal-styled (boot sequence types `music --boot --shuffle` + `[audio]` lines; track changes log `[audio] now: <title>` into the scrolling history). Pinned at the bottom of the terminal: 3-up `next/then/then` lines + arrow-pulse now-playing line. Below the terminal sits a dedicated **status strip** that always shows the chat command help (`!skip · !queue · !info`) — kept outside the scrolling area on purpose so it stays visible no matter what the terminal is showing. Subscribes to `mpd:state`; reads `nextTitles[]` from the bridge.
- `music-box/widget.html` — compact 549×880 sidebar widget for Desktop scene. Cyan-frame panel that pairs visually with the camera (camera is 1920×1080 × 0.286 ≈ 549×309; widget sits 18 px below at the same width). Same terminal pattern as the full-screen Music Box: scrolling history of `[audio] …` log lines on top, pinned 3-up next + arrow-pulse now-playing + chat-help line at bottom. Pinned block is **static HTML** (resilient to JS errors — the structure shows even if a script throws). **Must be authored at native pixel size** — scene item uses `bounds_type: 0` (no stretch).
- `music/index.html` — shared "Music Daemon" browser source. Predates the MPD bridge; today serves mainly to receive `cmd.js` polling for legacy command paths.
- `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client (HMAC-SHA256 auth, CEF `file://` `crypto.subtle` fallback). Used by every overlay.
- `vendor/obs-config.js` — auto-generated WS connection config (URL + password) extracted by `scripts/setup.sh`. Gitignored.
**Audio + automation surfaces (no OBS involvement until events flow through the bus):** **Audio + automation surfaces:**
- `bridges/mpd-state.py`polls MPD at 1 Hz + on track change, broadcasts `mpd:state` CustomEvents to the OBS WebSocket bus. Run as `obs-mpd-bridge.service` (systemd --user). - `bridges/mpd-state.py`MPD → OBS WS state bridge (`obs-mpd-bridge.service`).
- `twitch-bot/bot.py`connects to Twitch IRC as `vault118` (separate from broadcaster `ophi118`). Commands: `!skip` (per-user 60s cooldown — `SKIP_COOLDOWN`), `!queue` (peek next 3 tracks), `!info` (full metadata: artist · title · album · year · genre). When `MM_HOOK` is set in `.env`, a second async task polls the public `STATUS_URL` (default `https://ophi118.com/api/status.json`) every 60 s and POSTs a Mattermost webhook on each offline→live transition. Transition detection keys on Twitch's `started_at`, not just `live`, so a Helix flap inside one stream doesn't re-fire. Cold start mid-stream is intentionally silent (records the current `started_at`, awaits the next genuine transition). Run as `obs-twitch-bot.service` (systemd --user). - `twitch-bot/bot.py`Twitch chat bot for `!skip` / `!queue` / `!info` + optional Mattermost going-live notifier (`obs-twitch-bot.service`).
- `twitch-bot/search-game.py`, `set-channel.py`, `_twitch.py` Twitch Helix helpers used by `scripts/loading.sh` to resolve game IDs and update the channel title/category. - `twitch-bot/{search-game,set-channel,_twitch}.py` — Helix helpers used by `scripts/loading.sh`.
- `twitch-bot/.env`, `.env.ophi118` — secrets for the bot and broadcaster tokens. **Per-rig, regenerate on each machine, never copy.** - `twitch-bot/.env*` — secrets (per-rig, never copy).
**Scripts (all `.sh` in one place):** **Scripts (`.sh` lives only here):**
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig (Midgolem). 9 phases: pacman + pip + flatpak + submodules + PipeWire sink + MPD + systemd units + OBS first-launch + secrets stub. See file header. - `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig.
- `scripts/setup.sh` one-time generator for `vendor/obs-config.js` from the OBS WebSocket plugin config. Re-run after WS password/port changes. - `scripts/setup.sh` — generate `vendor/obs-config.js` after a WS password change.
- `scripts/telemetry.sh`see file header. `--collect` re-reads hardware + OBS + hostname into `landing/telemetry.json`, walks every field interactively for review/override, then wraps to `telemetry.js`. Plain run just re-wraps. - `scripts/telemetry.sh`refresh `landing/telemetry.json` + wrap to `.js`.
- `scripts/loading.sh` — interactive `loading.json` builder with previous-answer defaults; pushes title/game to Twitch via the broadcaster token if available. - `scripts/loading.sh` — interactive `loading.json` builder; pushes title/category to Twitch.
- `scripts/playlist.sh` — playlist sync (`scripts/playlist.sh sync` / default) and legacy daemon control (`skip`/`prev`/`pause`/`resume`/`status`). - `scripts/playlist.sh` — playlist sync + legacy daemon control.
- `scripts/convert.sh` — yt-dlp `.webm` → AAC `.m4a` re-encode in `playlist/`. Drop-in: chromium decodes `.webm` video tracks even when only the `<audio>` element renders, which pegs CPU mid-stream. - `scripts/convert.sh`, `scripts/clean.sh` — yt-dlp `.webm` → AAC `.m4a` re-encode + audit.
- `scripts/clean.sh` — read-only audit of `playlist/*.webm` vs sibling `.m4a` (which are converted, which still need conversion).
**Reference / data:** **Reference / data:** `playlist/` (gitignored library), `profile/` (rig spec docs), `station/` (git submodule for the public rig-spec page).
- `playlist/` — local audio library indexed by `scripts/playlist.sh`. Gitignored. Big.
- `profile/Ignia Spec.md`, `Midgolem Spec.md` — hardware spec docs for both rigs.
- `station/` — git submodule (`html-ophi118-rig`) — the public rig-spec page.
- `.gitmodules` — submodule registry.
## Audio architecture
Two parallel isolated streams into OBS — one for MPD music, one for Discord voice — so neither mixes with the rest of the desktop, and OBS gets independent meters / per-source volume for each.
```
┌────────────┐ ┌─────────────┐
│ MPD │ systemd user │ Discord │ Flatpak / native
└──────┬─────┘ unit └──────┬──────┘ app
│ PipeWire target= │ pavucontrol → "Discord-Stream"
│ "mpd_stream" │ (one-time per rig)
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ null sink "mpd_stream" │ │ null sink "discord_stream" │
└──────┬──────────┬──────┘ └──────┬─────────────┬───────┘
│ │ │ │
loopback│ │ monitor loopback│ │ monitor
▼ ▼ ▼ ▼
default OBS default OBS
sink pulse_output_ sink pulse_output_
capture capture
"MPD" "Discord"
(defined in pipewire-pulse.conf.d/
mpd-stream.conf, discord-stream.conf)
Twitch
```
The dedicated null sinks are the load-bearing piece: without them, OBS would either pick up *all* desktop audio (browser, system, every app) or *none*. The loopback modules mirror each null sink back to the user's actual default sink so MPD and Discord are still audible locally while OBS captures the isolated monitors.
**Routing the apps to their sinks:**
- **MPD** binds via `target "mpd_stream"` in `~/.config/mpd/mpd.conf` — automatic.
- **Discord** has no equivalent target setting. One-time per rig: open pavucontrol → Playback tab → while Discord plays sound, set its dropdown to **Discord-Stream**. PipeWire remembers across launches via `module-stream-restore`.
Sanity check after login:
```bash
pactl list short sinks | grep -E 'mpd_stream|discord_stream' # both should appear
```
If either is missing, `pipewire-pulse` didn't load the conf. Either relog or stop OBS and `systemctl --user restart pipewire-pulse.service` (do **not** restart pipewire-pulse while OBS is running — see "Critical gotcha" below).
## MPD setup
Config: `~/.config/mpd/mpd.conf`. Library: `~/HDD/Music`. Output target: `mpd_stream` (the null sink above). State files (`database`, `state`, `sticker.sql`, `playlists/`) live in `~/.config/mpd/`. Run via the system-provided units: `systemctl --user enable --now mpd.socket mpd.service`.
The `zeroconf_name` in `mpd.conf` is per-rig (`"MPD on Ignia"` / `"MPD on Midgolem"`) — `scripts/deploy-rig.sh` sets it to the local hostname when seeding the config.
`auto_update "yes"` means `inotify`-watch on the library — drop a file or `yt-dlp -P ~/HDD/Music/...` and MPD picks it up without `mpc update`.
## OBS WebSocket bus
Port 4455 (default). Password lives in `plugin_config/obs-websocket/config.json` (gitignored — secret). `scripts/setup.sh` extracts URL + password to `vendor/obs-config.js`, which every overlay loads as a `<script>` tag (no `fetch()` because CEF blocks it on `file://`).
Auth is HMAC-SHA256, handled by `vendor/obs-ws-mini.js`. The lib falls back to a pure-JS SHA-256 if `crypto.subtle` is unavailable (CEF on `file://` origins sometimes lacks it).
If `ss -tlnp | grep 4455` shows nothing, OBS's WS server is disabled — flip `Tools → WebSocket Server Settings → Enable` in the UI. The JSON file's `server_enabled` lags actual state until OBS shutdown, so trust the socket, not the file.
### Custom events on the bus
| Event | Publisher | Consumers |
|---|---|---|
| `mpd:state` | `bridges/mpd-state.py` (1 Hz + on track change) | `loading/index.html`, `game/index.html`, `desktop/index.html` (camera sync only), `goodbye/index.html`, `music-box/index.html`, `music-box/widget.html` |
| `mpd:cmd` | `music/index.html` (legacy; reads `loading/cmd.js`) | self-handled inside the music daemon |
`mpd:state` payload: `{_type, index, total, title, artist, trackTitle, file, currentTime, duration, paused, nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles}`. `nextTitles` is an array of up to 3 display strings peeked from the queue starting at the current song's successor (used by the Music Box overlays to show 3-up "coming next"). The single `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile` fields mirror `nextTitles[0]` for overlays that only need one. All `next*` fields are populated when MPD has a queued follow-up (`status.nextsong` present); they're `None`/`[]` at the end of the queue.
## Systemd user units
Live in `~/.config/systemd/user/`. Two custom + two system-provided:
| Unit | Source | Purpose |
|---|---|---|
| `obs-mpd-bridge.service` | `bridges/mpd-state.py` | MPD → OBS WS state bridge |
| `obs-twitch-bot.service` | `twitch-bot/bot.py` | Twitch chat → MPD control |
| `mpd.service` + `mpd.socket` | system | MPD itself (socket-activated) |
Common ops:
```bash
systemctl --user status obs-mpd-bridge obs-twitch-bot mpd
systemctl --user restart obs-mpd-bridge
journalctl --user -u obs-twitch-bot -f
```
`scripts/deploy-rig.sh` ships the two custom units inline (heredoc) — single source of truth for unit content lives in the deploy script, not in this repo as a separate file.
## Twitch identity & secrets
| Account | Purpose | Token file | Scopes |
|---|---|---|---|
| `ophi118` (broadcaster) | Channel title/category updates by `set-channel.py` | `twitch-bot/.env.ophi118` | `channel:manage:broadcast` |
| `vault118` (bot) | Handles `!skip` / `!queue` / `!info`, posts now-playing, optional Mattermost going-live notifier (outbound HTTP only — no extra Twitch scopes needed) | `twitch-bot/.env` | `chat:read`, `chat:edit` |
| `ophi118` (stream key) | RTMP stream key | `basic/profiles/ophi118/service.json` | n/a |
All three are gitignored. **Do not copy between rigs** — regenerate via https://twitchtokengenerator.com (for OAuth tokens) and the Twitch Dashboard (for the stream key).
## External assets referenced by absolute path
These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fresh rig they need to exist or OBS will surface "missing source" errors:
| Path | Purpose |
|---|---|
| `/home/jin/HDD/Images/Gifs/stamd.png` | Game-scene "Starting Soon" image |
| `/home/jin/cloud.jakubzych.com/_img/logos/dgw.png` | Doomguard watermark (Game/Landing) |
| `/home/jin/HDD/Music/` | MPD library root |
| `/home/jin/Videos/` | Recording output |
| `/home/jin/.config/obs-studio/landing/static-hum.wav` | Landing audio |
`scripts/deploy-rig.sh` creates the directories and warns about missing image files; the user has to bring those over manually.
## Per-rig vs portable state
**Differs per rig** (don't copy from one to the other; let the deploy script or OBS itself regenerate):
- `global.ini` — ALSA device IDs, monitor IDs
- `~/.config/mpd/mpd.conf``zeroconf_name`
- `twitch-bot/.env*` — OAuth tokens
- `basic/profiles/ophi118/service.json` — stream key
- `plugin_config/obs-websocket/config.json` — WebSocket password
- `landing/telemetry.json``rig`, hardware specs, kernel
- `vendor/obs-config.js` — derived from WS password
**Identical across rigs** (commit and reuse):
- `basic/scenes/Default_Stream_HUD.json` — scenes + transitions + audio routing
- `basic/profiles/ophi118/{basic,streamEncoder,recordEncoder}.json` — encoder/output settings (minus `service.json`)
- All HTML overlays under `landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/`
- All Python in `bridges/`, `twitch-bot/`
- All scripts in `scripts/`
- `vendor/obs-ws-mini.js`
**Outside this repo but written by `scripts/deploy-rig.sh` (identical across rigs):**
- `~/.config/pipewire/pipewire-pulse.conf.d/mpd-stream.conf` — MPD null sink + loopback
- `~/.config/pipewire/pipewire-pulse.conf.d/discord-stream.conf` — Discord null sink + loopback
**Manual one-time per rig (no automation):**
- pavucontrol → Playback → Discord → set output to **Discord-Stream** (PipeWire remembers across launches; Discord has no equivalent of MPD's `target` config).
## Migration history (matters when reading old scene JSON or AI-written files)
- **Was:** native Arch `obs-studio` (no browser support) + AUR `obs-linuxbrowser-source` (id: `linuxbrowser-source`).
- **2026-04-25:** migrated to Flatpak `com.obsproject.Studio` with bundled CEF browser source (id: `browser_source`). Settings keys: `url`, `width`, `height`, `fps` map directly; CEF requires `fps_custom: true`, `reroute_audio: false`, `restart_when_active: false`, `shutdown: false`, `webpage_control_level: 1`. Stale `linuxbrowser.reloadpage` hotkey keys dropped. Pre-migration backups have been deleted as of 2026-04-26.
- **2026-04-26:** music architecture moved from a browser-side daemon (`music/index.html`) to MPD + a Python state bridge (`bridges/mpd-state.py`). Twitch chat bot (`twitch-bot/bot.py`) added with `!skip → MPD` integration. `station/` submodule added for the public rig-spec page. Game scene HUD added with live OBS WS sync. All `.sh` scripts consolidated under `scripts/`.
- **2026-04-26 (later):** Music Box scene added (`music-box/index.html`, full-screen) plus sidebar widget (`music-box/widget.html`) for the Desktop scene. Desktop scene reworked Game-style — camera in right sidebar with the Music Box widget directly below. Desktop HUD forked from Game HUD into `desktop/index.html` (no game-name slot). Bridge payload extended with `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile`. Bot gained `!queue` (next 3 tracks) and `!info` (full metadata) commands; `!skip` cooldown shifted from global 5s to per-user 60s.
- **2026-04-27:** Music Box overlays rewritten as proper terminals (boot sequence + scrolling `[audio] now: …` history + static-HTML pinned block + ellipsis-clipped track titles). Music Box overlay gained a dedicated chat status strip between the terminal and the banner so the command help stays visible regardless of what's scrolling. Music Box widget resized to 549×880 to pair with camera width. Bridge payload gained `nextTitles[]` (array of next 3 display strings) so Music Box can show 3-up "coming next". Bot gained `live_watch()` — optional Mattermost going-live notifier driven by `MM_HOOK` env var. Landing top HUD identifier upgraded to `OPHI-118 // [<RIG>]` with cyberpunk decode/scramble reveal animation reading `window.__TEL.rig` from telemetry.
- **2026-04-28:** Discord audio split out to its own isolated stream — `~/.config/pipewire/pipewire-pulse.conf.d/discord-stream.conf` adds a `discord_stream` null sink + loopback (mirrors the existing `mpd-stream.conf` pattern). New OBS source `Discord` (`pulse_output_capture` on `discord_stream.monitor`) added to scenes Game / Desktop / Desktop (No Cam) / Music Box. `scripts/deploy-rig.sh` phase 5 now provisions both sinks and skips the `pipewire-pulse` restart when OBS is running (a restart silently detaches OBS audio sources — fix needs an OBS process restart, not just a settings refresh). Discord routing to its sink is the only manual per-rig step (pavucontrol → Playback → Discord → Discord-Stream).
- If you encounter `linuxbrowser-source` anywhere — in a backup, a doc, a paste — assume it's pre-migration and migrate the same way.
## Critical gotcha: OBS overwrites on exit ## Critical gotcha: OBS overwrites on exit
OBS rewrites `basic/scenes/<collection>.json`, `global.ini`, and `user.ini` on shutdown (and periodically). **Close OBS before editing these files**, or the next OBS exit will clobber the edit. After editing, re-launch OBS to load the change. OBS rewrites `basic/scenes/<collection>.json`, `global.ini`, and `user.ini` on shutdown (and periodically). **Close OBS before editing these files**, or the next OBS exit will clobber the edit. After editing, re-launch OBS to load the change.
**HTML overlays (`landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/`) are safe to edit while OBS is up** — CEF re-reads them on demand. Apply the change with right-click source → **Refresh cache** in OBS, no restart needed. **HTML overlays under `landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/` are safe to edit while OBS is up** — CEF re-reads them on demand. Apply the change with right-click source → **Refresh cache** in OBS, no restart needed.
**Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use: **Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use:
```bash ```bash
@@ -241,93 +82,19 @@ If you need to apply a new conf in `~/.config/pipewire/pipewire-pulse.conf.d/`:
- **Preferred**: load the modules ad-hoc (`pactl load-module module-null-sink …` + `pactl load-module module-loopback …`) — the conf still takes over on the next pipewire-pulse start, no restart needed. - **Preferred**: load the modules ad-hoc (`pactl load-module module-null-sink …` + `pactl load-module module-loopback …`) — the conf still takes over on the next pipewire-pulse start, no restart needed.
- **If unavoidable**: stop OBS first (`flatpak kill com.obsproject.Studio`), restart pipewire-pulse, then start OBS. - **If unavoidable**: stop OBS first (`flatpak kill com.obsproject.Studio`), restart pipewire-pulse, then start OBS.
`scripts/deploy-rig.sh` phase 5 already guards against this: when OBS is running, it skips the restart and prints a warning. On a fresh Midgolem deploy OBS hasn't started yet, so the restart there is safe. `scripts/deploy-rig.sh` phase 5 already guards against this: when OBS is running, it skips the restart and prints a warning.
## Flatpak permissions — what the sandbox allows ## User-specific conventions
OBS flatpak ships with `filesystems=host` by default — full host access. Asset paths under `/home/jin/HDD/`, `/home/jin/cloud.jakubzych.com/`, etc. work without `flatpak override`. PipeWire audio works via `xdg-run/pipewire-0`. NVIDIA acceleration works via `org.freedesktop.Platform.GL.nvidia-*` (auto-installed). If you ever need to widen permissions: `flatpak override --user com.obsproject.Studio --<flag>=<value>`.
`filesystems=host` is also the reason this config lives at `~/.config/obs-studio/` instead of the per-app sandboxed path — see "What this directory is" above.
## Scene collection JSON — structure to know before editing
Top-level keys in `basic/scenes/Default_Stream_HUD.json`:
- `name` — collection name (matches filename stem).
- `sources`**flat array of every source, scene, and group**. Scenes are entries here with `"id": "scene"`; their `settings.items[]` references child sources **by name string**, not uuid. Renaming a source means updating every scene that references it.
- `scene_order` — array of `{name}` defining sidebar order.
- `current_scene` / `current_program_scene` — strings matching scene names.
- `transitions`, `quick_transitions`, `current_transition`, `transition_duration` — transition setup.
- `groups` — group definitions.
- `modules` — per-source-plugin state.
- `version`, `migration_resolution`, `resolution`, `canvases` — collection schema + canvas sizing. Canvas: **2560×1336**.
Each source entry has: `name`, `uuid`, `id`, `versioned_id`, `settings` (plugin-specific), plus filters/transform/audio fields. Scene items inside `settings.items[]` reference sources via `source_uuid`. **Use uuids when adding new items programmatically; use names when reading.**
Source `id` values currently in use:
- `scene` — a scene
- `pipewire-screen-capture-source` — Wayland/PipeWire screen/window capture
- `v4l2_input` — V4L2 webcam (legacy path)
- `browser_source` — CEF browser (post-migration)
- `image_source` — static image
- `text_ft2_source` — FreeType2 text
- `ffmpeg_source` — media file (audio/video) via ffmpeg
- `pulse_output_capture` / `pulse_input_capture` — PulseAudio sink/source capture (used for `mpd_stream.monitor` and the mic)
When editing scene JSON: keep `uuid` values stable across edits, preserve 4-space indentation, and validate with `python3 -m json.tool` before re-launching OBS.
## Game / Desktop / Music Box HUDs — browser-resolution constraints
The Game (`HUD`), Desktop (`Desktop HUD`), and Music Box (`Music Box HUD`) browser sources all stretch (`bounds_type: 1`, bounds 2560×1336) to fill the canvas. **Their `width` / `height` settings must match the canvas (2560×1336)**, not 16:9 like the output resolution. If the browser viewport is 1920×1080, the canvas stretch is non-uniform (1.333× horizontal, 1.237× vertical), which distorts every CSS box relative to the camera's transform (camera is positioned in canvas coords directly, so a CSS-positioned frame around it looks visibly off — wider/squashed than the camera). Symptom: cyan "camera frame" doesn't align with the camera feed inside it. Fix: keep all three browser sources at `width: 2560, height: 1336` so 1 CSS px = 1 canvas px, then position any frame using canvas coords.
The Camera frame in both `game/index.html` and `desktop/index.html` auto-syncs via OBS WebSocket: `GetSceneItemTransform` on connect + `SceneItemTransformChanged` event listener update four CSS vars (`--cam-x/y/w/h`). Move/resize the Camera item in OBS and the frame follows live. Assumes Camera item alignment 5 (top-left) — OBS default; if you change alignment, the math in `setCameraTransform()` needs adjusting. The `?scene=` and `?camera=` URL params let either HUD target a different scene or camera source name.
**The Music Box widget** (`music-box/widget.html`) at 549×880 is the exception: it sits in canvas coords as a native-sized scene item (`bounds_type: 0`), NOT stretched. Authoring viewport must match scene-item dimensions; do not toggle `bounds_type` on without resizing the source's `width`/`height` to match the new bounds. The 549 width is deliberate — camera renders at 1920×1080 × 0.286 = 549×309, so the widget pairs visually as a same-width column directly under the camera.
## Working with this directory
- **Confirm OBS is using THIS dir, not the ghost** before assuming an edit will land. While OBS is running: `ls -la /proc/$(pgrep -x obs)/cwd` and check `lsof -p $(pgrep -x obs) | grep scenes`. Or compare mtimes after an OBS session — the file OBS rewrites is the live one.
- **Inspecting a scene collection:** `python3 -m json.tool basic/scenes/Default_Stream_HUD.json | less`. List scenes: `python3 -c "import json; d=json.load(open('basic/scenes/Default_Stream_HUD.json')); print([s['name'] for s in d['scene_order']])"`.
- **Diffing after an OBS session:** OBS rewrites the file even when nothing visibly changed. Expect noisy diffs; focus on `sources[]` and `scene_order` for meaningful changes.
- **Backing up before edits:** copy the active `.json` somewhere outside this dir — OBS's own `.bak` will be overwritten on the next save.
- **Plugin development:** Flatpak OBS plugins live inside the runtime; you can't drop `.so` files into the host filesystem and have them load. Plugin extensions for the OBS flatpak come as `com.obsproject.Studio.Plugin.<name>` flatpak extensions (search Flathub). If a plugin only has source/AUR distribution, it won't work in Flatpak — that's the tradeoff of this distribution choice.
- **WebSocket control:** `plugin_config/obs-websocket/config.json` holds connection settings. Programmatic scene edits while OBS is running should go through obs-websocket — file edits get clobbered on save.
## One-liners
```bash
# Refresh a browser source after editing HTML
# In OBS: right-click the source → Refresh cache (no restart needed)
# Regenerate vendor/obs-config.js after WS password change
bash scripts/setup.sh
# Refresh telemetry (interactive — review every field)
bash scripts/telemetry.sh --collect
# Rebuild the playlist index after dropping new files into ~/HDD/Music
bash scripts/playlist.sh
# Deploy this rig from scratch (run on Midgolem)
git clone <repo> ~/.config/obs-studio && cd ~/.config/obs-studio && bash scripts/deploy-rig.sh
# Bridge / bot status + tail
systemctl --user status obs-mpd-bridge obs-twitch-bot
journalctl --user -u obs-twitch-bot -f
# Verify both audio sinks exist
pactl list short sinks | grep -E 'mpd_stream|discord_stream'
# Check OBS WS port is listening
ss -tlnp | grep 4455
# Is OBS running? (flatpak-aware — DO NOT use `pgrep -fa 'flatpak run …'`)
flatpak ps --columns=application | grep -qx com.obsproject.Studio && echo running
```
## User-specific conventions (from global instructions)
- Never add co-author tags to commit messages. - Never add co-author tags to commit messages.
- On any path error, immediately run `pwd` to confirm location. - On any path error, immediately run `pwd` to confirm location.
- `ssu` is available for git submodule management. - `ssu` is available for git submodule management.
- Core plugins (user, blog, pages, payment) are reused across many projects — no breaking changes unless explicitly asked.
## Deeper context — read these when relevant
- **[docs/audio.md](docs/audio.md)** — PipeWire null-sink architecture, MPD config, OBS WebSocket bus + `mpd:state` payload, systemd user units. Read when editing `bridges/`, `twitch-bot/`, MPD config, pipewire confs, or working with WS custom events.
- **[docs/scenes-and-overlays.md](docs/scenes-and-overlays.md)** — Scene JSON structure, source ids, HUD browser-resolution constraints, camera-frame OBS-sync, plugin & Flatpak quirks. Read when editing `basic/scenes/`, building HUDs, or driving OBS programmatically.
- **[docs/deployment.md](docs/deployment.md)** — Deploy a fresh rig, Twitch identity & secrets, hardcoded asset paths, per-rig vs portable state, Flatpak permissions. Read when bringing up a new rig, rotating tokens, or copying state between Ignia and Midgolem.
- **[docs/history.md](docs/history.md)** — Migration history (linuxbrowser → CEF, browser-daemon → MPD bridge, Discord audio split, etc). Read when encountering pre-migration artifacts or auditing stale conventions.
- **[docs/cheatsheet.md](docs/cheatsheet.md)** — One-liners for common ops.

87
docs/audio.md Normal file
View File

@@ -0,0 +1,87 @@
# Audio pipeline, MPD, OBS WS bus, systemd units
**Read when:** editing `bridges/`, `twitch-bot/`, `~/.config/mpd/mpd.conf`, `~/.config/pipewire/pipewire-pulse.conf.d/*.conf`, or working with `mpd:state` events on the OBS WebSocket bus. Also read before touching audio routing in scene JSON.
## Audio architecture
Two parallel isolated streams into OBS — one for MPD music, one for Discord voice — so neither mixes with the rest of the desktop, and OBS gets independent meters / per-source volume for each.
```
┌────────────┐ ┌─────────────┐
│ MPD │ systemd user │ Discord │ Flatpak / native
└──────┬─────┘ unit └──────┬──────┘ app
│ PipeWire target= │ pavucontrol → "Discord-Stream"
│ "mpd_stream" │ (one-time per rig)
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ null sink "mpd_stream" │ │ null sink "discord_stream" │
└──────┬──────────┬──────┘ └──────┬─────────────┬───────┘
│ │ │ │
loopback│ │ monitor loopback│ │ monitor
▼ ▼ ▼ ▼
default OBS default OBS
sink pulse_output_ sink pulse_output_
capture capture
"MPD" "Discord"
(defined in pipewire-pulse.conf.d/
mpd-stream.conf, discord-stream.conf)
Twitch
```
The dedicated null sinks are the load-bearing piece: without them, OBS would either pick up *all* desktop audio (browser, system, every app) or *none*. The loopback modules mirror each null sink back to the user's actual default sink so MPD and Discord are still audible locally while OBS captures the isolated monitors.
**Routing the apps to their sinks:**
- **MPD** binds via `target "mpd_stream"` in `~/.config/mpd/mpd.conf` — automatic.
- **Discord** has no equivalent target setting. One-time per rig: open pavucontrol → Playback tab → while Discord plays sound, set its dropdown to **Discord-Stream**. PipeWire remembers across launches via `module-stream-restore`.
Sanity check after login:
```bash
pactl list short sinks | grep -E 'mpd_stream|discord_stream' # both should appear
```
If either is missing, `pipewire-pulse` didn't load the conf. Either relog or stop OBS and `systemctl --user restart pipewire-pulse.service` (do **not** restart pipewire-pulse while OBS is running — see CLAUDE.md "Critical gotcha").
## MPD setup
Config: `~/.config/mpd/mpd.conf`. Library: `~/HDD/Music`. Output target: `mpd_stream` (the null sink above). State files (`database`, `state`, `sticker.sql`, `playlists/`) live in `~/.config/mpd/`. Run via the system-provided units: `systemctl --user enable --now mpd.socket mpd.service`.
The `zeroconf_name` in `mpd.conf` is per-rig (`"MPD on Ignia"` / `"MPD on Midgolem"`) — `scripts/deploy-rig.sh` sets it to the local hostname when seeding the config.
`auto_update "yes"` means `inotify`-watch on the library — drop a file or `yt-dlp -P ~/HDD/Music/...` and MPD picks it up without `mpc update`.
## OBS WebSocket bus
Port 4455 (default). Password lives in `plugin_config/obs-websocket/config.json` (gitignored — secret). `scripts/setup.sh` extracts URL + password to `vendor/obs-config.js`, which every overlay loads as a `<script>` tag (no `fetch()` because CEF blocks it on `file://`).
Auth is HMAC-SHA256, handled by `vendor/obs-ws-mini.js`. The lib falls back to a pure-JS SHA-256 if `crypto.subtle` is unavailable (CEF on `file://` origins sometimes lacks it).
If `ss -tlnp | grep 4455` shows nothing, OBS's WS server is disabled — flip `Tools → WebSocket Server Settings → Enable` in the UI. The JSON file's `server_enabled` lags actual state until OBS shutdown, so trust the socket, not the file.
### Custom events on the bus
| Event | Publisher | Consumers |
|---|---|---|
| `mpd:state` | `bridges/mpd-state.py` (1 Hz + on track change) | `loading/index.html`, `game/index.html`, `desktop/index.html` (camera sync only), `goodbye/index.html`, `music-box/index.html`, `music-box/widget.html` |
| `mpd:cmd` | `music/index.html` (legacy; reads `loading/cmd.js`) | self-handled inside the music daemon |
`mpd:state` payload: `{_type, index, total, title, artist, trackTitle, file, currentTime, duration, paused, nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles}`. `nextTitles` is an array of up to 3 display strings peeked from the queue starting at the current song's successor (used by the Music Box overlays to show 3-up "coming next"). The single `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile` fields mirror `nextTitles[0]` for overlays that only need one. All `next*` fields are populated when MPD has a queued follow-up (`status.nextsong` present); they're `None`/`[]` at the end of the queue.
## Systemd user units
Live in `~/.config/systemd/user/`. Two custom + two system-provided:
| Unit | Source | Purpose |
|---|---|---|
| `obs-mpd-bridge.service` | `bridges/mpd-state.py` | MPD → OBS WS state bridge |
| `obs-twitch-bot.service` | `twitch-bot/bot.py` | Twitch chat → MPD control |
| `mpd.service` + `mpd.socket` | system | MPD itself (socket-activated) |
Common ops:
```bash
systemctl --user status obs-mpd-bridge obs-twitch-bot mpd
systemctl --user restart obs-mpd-bridge
journalctl --user -u obs-twitch-bot -f
```
`scripts/deploy-rig.sh` ships the two custom units inline (heredoc) — single source of truth for unit content lives in the deploy script, not in this repo as a separate file.

39
docs/cheatsheet.md Normal file
View File

@@ -0,0 +1,39 @@
# One-liners
**Read when:** you want a quick command reference. These are pasted directly from typical workflows.
```bash
# Refresh a browser source after editing HTML
# In OBS: right-click the source → Refresh cache (no restart needed)
# Regenerate vendor/obs-config.js after WS password change
bash scripts/setup.sh
# Refresh telemetry (interactive — review every field)
bash scripts/telemetry.sh --collect
# Rebuild the playlist index after dropping new files into ~/HDD/Music
bash scripts/playlist.sh
# Deploy this rig from scratch (run on Midgolem)
git clone <repo> ~/.config/obs-studio && cd ~/.config/obs-studio && bash scripts/deploy-rig.sh
# Bridge / bot status + tail
systemctl --user status obs-mpd-bridge obs-twitch-bot
journalctl --user -u obs-twitch-bot -f
# Verify both audio sinks exist
pactl list short sinks | grep -E 'mpd_stream|discord_stream'
# Check OBS WS port is listening
ss -tlnp | grep 4455
# Is OBS running? (flatpak-aware — DO NOT use `pgrep -fa 'flatpak run …'`)
flatpak ps --columns=application | grep -qx com.obsproject.Studio && echo running
# Inspect a scene collection
python3 -m json.tool basic/scenes/Default_Stream_HUD.json | less
# List scenes in the active collection
python3 -c "import json; d=json.load(open('basic/scenes/Default_Stream_HUD.json')); print([s['name'] for s in d['scene_order']])"
```

69
docs/deployment.md Normal file
View File

@@ -0,0 +1,69 @@
# Deployment, per-rig state, secrets, sandbox
**Read when:** bringing up a fresh rig (Midgolem or otherwise), rotating tokens, deciding what to commit vs regenerate, or hitting Flatpak sandbox / hardcoded-asset issues.
## Deploy a fresh rig
```bash
git clone <repo> ~/.config/obs-studio
cd ~/.config/obs-studio
bash scripts/deploy-rig.sh
```
Idempotent. 9 phases: pacman + pip + flatpak + submodules + PipeWire sink + MPD + systemd units + OBS first-launch + secrets stub. See file header for details.
## Twitch identity & secrets
| Account | Purpose | Token file | Scopes |
|---|---|---|---|
| `ophi118` (broadcaster) | Channel title/category updates by `set-channel.py` | `twitch-bot/.env.ophi118` | `channel:manage:broadcast` |
| `vault118` (bot) | Handles `!skip` / `!queue` / `!info`, posts now-playing, optional Mattermost going-live notifier (outbound HTTP only — no extra Twitch scopes needed) | `twitch-bot/.env` | `chat:read`, `chat:edit` |
| `ophi118` (stream key) | RTMP stream key | `basic/profiles/ophi118/service.json` | n/a |
All three are gitignored. **Do not copy between rigs** — regenerate via https://twitchtokengenerator.com (for OAuth tokens) and the Twitch Dashboard (for the stream key).
## External assets referenced by absolute path
These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fresh rig they need to exist or OBS will surface "missing source" errors:
| Path | Purpose |
|---|---|
| `/home/jin/HDD/Images/Gifs/stamd.png` | Game-scene "Starting Soon" image |
| `/home/jin/cloud.jakubzych.com/_img/logos/dgw.png` | Doomguard watermark (Game/Landing) |
| `/home/jin/HDD/Music/` | MPD library root |
| `/home/jin/Videos/` | Recording output |
| `/home/jin/.config/obs-studio/landing/static-hum.wav` | Landing audio |
`scripts/deploy-rig.sh` creates the directories and warns about missing image files; the user has to bring those over manually.
## Per-rig vs portable state
**Differs per rig** (don't copy from one to the other; let the deploy script or OBS itself regenerate):
- `global.ini` — ALSA device IDs, monitor IDs
- `~/.config/mpd/mpd.conf``zeroconf_name`
- `twitch-bot/.env*` — OAuth tokens
- `basic/profiles/ophi118/service.json` — stream key
- `plugin_config/obs-websocket/config.json` — WebSocket password
- `landing/telemetry.json``rig`, hardware specs, kernel
- `vendor/obs-config.js` — derived from WS password
**Identical across rigs** (commit and reuse):
- `basic/scenes/Default_Stream_HUD.json` — scenes + transitions + audio routing
- `basic/profiles/ophi118/{basic,streamEncoder,recordEncoder}.json` — encoder/output settings (minus `service.json`)
- All HTML overlays under `landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/`
- All Python in `bridges/`, `twitch-bot/`
- All scripts in `scripts/`
- `vendor/obs-ws-mini.js`
**Outside this repo but written by `scripts/deploy-rig.sh` (identical across rigs):**
- `~/.config/pipewire/pipewire-pulse.conf.d/mpd-stream.conf` — MPD null sink + loopback
- `~/.config/pipewire/pipewire-pulse.conf.d/discord-stream.conf` — Discord null sink + loopback
**Manual one-time per rig (no automation):**
- pavucontrol → Playback → Discord → set output to **Discord-Stream** (PipeWire remembers across launches; Discord has no equivalent of MPD's `target` config).
## Flatpak permissions — what the sandbox allows
OBS flatpak ships with `filesystems=host` by default — full host access. Asset paths under `/home/jin/HDD/`, `/home/jin/cloud.jakubzych.com/`, etc. work without `flatpak override`. PipeWire audio works via `xdg-run/pipewire-0`. NVIDIA acceleration works via `org.freedesktop.Platform.GL.nvidia-*` (auto-installed). If you ever need to widen permissions: `flatpak override --user com.obsproject.Studio --<flag>=<value>`.
`filesystems=host` is also the reason this config lives at `~/.config/obs-studio/` instead of the per-app sandboxed path — see CLAUDE.md "What this directory is".

12
docs/history.md Normal file
View File

@@ -0,0 +1,12 @@
# Migration history
**Read when:** encountering pre-migration artifacts (e.g. `linuxbrowser-source` source ids, references to native `obs` binary, browser-side music daemon code paths), reading old scene JSON, or auditing AI-written files that may target stale conventions.
- **Was:** native Arch `obs-studio` (no browser support) + AUR `obs-linuxbrowser-source` (id: `linuxbrowser-source`).
- **2026-04-25:** migrated to Flatpak `com.obsproject.Studio` with bundled CEF browser source (id: `browser_source`). Settings keys: `url`, `width`, `height`, `fps` map directly; CEF requires `fps_custom: true`, `reroute_audio: false`, `restart_when_active: false`, `shutdown: false`, `webpage_control_level: 1`. Stale `linuxbrowser.reloadpage` hotkey keys dropped. Pre-migration backups have been deleted as of 2026-04-26.
- **2026-04-26:** music architecture moved from a browser-side daemon (`music/index.html`) to MPD + a Python state bridge (`bridges/mpd-state.py`). Twitch chat bot (`twitch-bot/bot.py`) added with `!skip → MPD` integration. `station/` submodule added for the public rig-spec page. Game scene HUD added with live OBS WS sync. All `.sh` scripts consolidated under `scripts/`.
- **2026-04-26 (later):** Music Box scene added (`music-box/index.html`, full-screen) plus sidebar widget (`music-box/widget.html`) for the Desktop scene. Desktop scene reworked Game-style — camera in right sidebar with the Music Box widget directly below. Desktop HUD forked from Game HUD into `desktop/index.html` (no game-name slot). Bridge payload extended with `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile`. Bot gained `!queue` (next 3 tracks) and `!info` (full metadata) commands; `!skip` cooldown shifted from global 5s to per-user 60s.
- **2026-04-27:** Music Box overlays rewritten as proper terminals (boot sequence + scrolling `[audio] now: …` history + static-HTML pinned block + ellipsis-clipped track titles). Music Box overlay gained a dedicated chat status strip between the terminal and the banner so the command help stays visible regardless of what's scrolling. Music Box widget resized to 549×880 to pair with camera width. Bridge payload gained `nextTitles[]` (array of next 3 display strings) so Music Box can show 3-up "coming next". Bot gained `live_watch()` — optional Mattermost going-live notifier driven by `MM_HOOK` env var. Landing top HUD identifier upgraded to `OPHI-118 // [<RIG>]` with cyberpunk decode/scramble reveal animation reading `window.__TEL.rig` from telemetry.
- **2026-04-28:** Discord audio split out to its own isolated stream — `~/.config/pipewire/pipewire-pulse.conf.d/discord-stream.conf` adds a `discord_stream` null sink + loopback (mirrors the existing `mpd-stream.conf` pattern). New OBS source `Discord` (`pulse_output_capture` on `discord_stream.monitor`) added to scenes Game / Desktop / Desktop (No Cam) / Music Box. `scripts/deploy-rig.sh` phase 5 now provisions both sinks and skips the `pipewire-pulse` restart when OBS is running (a restart silently detaches OBS audio sources — fix needs an OBS process restart, not just a settings refresh). Discord routing to its sink is the only manual per-rig step (pavucontrol → Playback → Discord → Discord-Stream).
If you encounter `linuxbrowser-source` anywhere — in a backup, a doc, a paste — assume it's pre-migration and migrate the same way.

View File

@@ -0,0 +1,49 @@
# Scene JSON, HUD overlays, OBS internals
**Read when:** editing `basic/scenes/Default_Stream_HUD.json`, building or modifying the HTML HUDs (`landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/`), debugging plugin behavior, or programmatically driving OBS.
## Scene collection JSON — structure
Top-level keys in `basic/scenes/Default_Stream_HUD.json`:
- `name` — collection name (matches filename stem).
- `sources`**flat array of every source, scene, and group**. Scenes are entries here with `"id": "scene"`; their `settings.items[]` references child sources **by name string**, not uuid. Renaming a source means updating every scene that references it.
- `scene_order` — array of `{name}` defining sidebar order.
- `current_scene` / `current_program_scene` — strings matching scene names.
- `transitions`, `quick_transitions`, `current_transition`, `transition_duration` — transition setup.
- `groups` — group definitions.
- `modules` — per-source-plugin state.
- `version`, `migration_resolution`, `resolution`, `canvases` — collection schema + canvas sizing. Canvas: **2560×1336**.
Each source entry has: `name`, `uuid`, `id`, `versioned_id`, `settings` (plugin-specific), plus filters/transform/audio fields. Scene items inside `settings.items[]` reference sources via `source_uuid`. **Use uuids when adding new items programmatically; use names when reading.**
Source `id` values currently in use:
- `scene` — a scene
- `pipewire-screen-capture-source` — Wayland/PipeWire screen/window capture
- `v4l2_input` — V4L2 webcam (legacy path)
- `browser_source` — CEF browser (post-migration)
- `image_source` — static image
- `text_ft2_source` — FreeType2 text
- `ffmpeg_source` — media file (audio/video) via ffmpeg
- `pulse_output_capture` / `pulse_input_capture` — PulseAudio sink/source capture (used for `mpd_stream.monitor` and the mic)
When editing scene JSON: keep `uuid` values stable across edits, preserve 4-space indentation, and validate with `python3 -m json.tool` before re-launching OBS.
## Game / Desktop / Music Box HUDs — browser-resolution constraints
The Game (`HUD`), Desktop (`Desktop HUD`), and Music Box (`Music Box HUD`) browser sources all stretch (`bounds_type: 1`, bounds 2560×1336) to fill the canvas. **Their `width` / `height` settings must match the canvas (2560×1336)**, not 16:9 like the output resolution. If the browser viewport is 1920×1080, the canvas stretch is non-uniform (1.333× horizontal, 1.237× vertical), which distorts every CSS box relative to the camera's transform (camera is positioned in canvas coords directly, so a CSS-positioned frame around it looks visibly off — wider/squashed than the camera). Symptom: cyan "camera frame" doesn't align with the camera feed inside it. Fix: keep all three browser sources at `width: 2560, height: 1336` so 1 CSS px = 1 canvas px, then position any frame using canvas coords.
The Camera frame in both `game/index.html` and `desktop/index.html` auto-syncs via OBS WebSocket: `GetSceneItemTransform` on connect + `SceneItemTransformChanged` event listener update four CSS vars (`--cam-x/y/w/h`). Move/resize the Camera item in OBS and the frame follows live. Assumes Camera item alignment 5 (top-left) — OBS default; if you change alignment, the math in `setCameraTransform()` needs adjusting. The `?scene=` and `?camera=` URL params let either HUD target a different scene or camera source name.
**The Music Box widget** (`music-box/widget.html`) at 549×880 is the exception: it sits in canvas coords as a native-sized scene item (`bounds_type: 0`), NOT stretched. Authoring viewport must match scene-item dimensions; do not toggle `bounds_type` on without resizing the source's `width`/`height` to match the new bounds. The 549 width is deliberate — camera renders at 1920×1080 × 0.286 = 549×309, so the widget pairs visually as a same-width column directly under the camera.
## Working with scene JSON & overlays
- **Confirm OBS is using THIS dir, not the ghost** before assuming an edit will land. While OBS is running: `ls -la /proc/$(pgrep -x obs)/cwd` and check `lsof -p $(pgrep -x obs) | grep scenes`. Or compare mtimes after an OBS session — the file OBS rewrites is the live one.
- **Inspecting a scene collection:** `python3 -m json.tool basic/scenes/Default_Stream_HUD.json | less`. List scenes: `python3 -c "import json; d=json.load(open('basic/scenes/Default_Stream_HUD.json')); print([s['name'] for s in d['scene_order']])"`.
- **Diffing after an OBS session:** OBS rewrites the file even when nothing visibly changed. Expect noisy diffs; focus on `sources[]` and `scene_order` for meaningful changes.
- **Backing up before edits:** copy the active `.json` somewhere outside this dir — OBS's own `.bak` will be overwritten on the next save.
- **Refreshing a browser source after editing HTML:** right-click source in OBS → **Refresh cache**. No restart needed — CEF re-reads on demand.
- **Plugin development:** Flatpak OBS plugins live inside the runtime; you can't drop `.so` files into the host filesystem and have them load. Plugin extensions for the OBS flatpak come as `com.obsproject.Studio.Plugin.<name>` flatpak extensions (search Flathub). If a plugin only has source/AUR distribution, it won't work in Flatpak — that's the tradeoff of this distribution choice.
- **WebSocket control:** `plugin_config/obs-websocket/config.json` holds connection settings. Programmatic scene edits while OBS is running should go through obs-websocket — file edits get clobbered on save.