# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## 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/`).

## What this directory is

Active OBS Studio user config for the **Flatpak** install (`com.obsproject.Studio` from Flathub). Path: `~/.config/obs-studio/`.

**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.

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

```
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.

## Layout — what lives where

**OBS-owned (don't edit while OBS is running — see "OBS overwrites on exit"):**
- `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).
- `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/profiles/<name>/` — encoder, streaming service, recording settings (separate from scenes). Active profile: `ophi118`.
- `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).
- `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`.
- `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):**
- `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/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`.
- `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.
- `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.
- `loading/loading.json` / `loading.js` — per-session manifest (game, subtitle, countdown, camera/mic flags). Written by `scripts/loading.sh`.
- `loading/playlist.json` / `playlist.js` — flat library index built by `scripts/playlist.sh`.
- `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.
- `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.
- `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`.
- `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):**
- `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).
- `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/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/.env`, `.env.ophi118` — secrets for the bot and broadcaster tokens. **Per-rig, regenerate on each machine, never copy.**

**Scripts (all `.sh` in one place):**
- `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/setup.sh` — one-time generator for `vendor/obs-config.js` from the OBS WebSocket plugin config. Re-run after WS password/port changes.
- `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/loading.sh` — interactive `loading.json` builder with previous-answer defaults; pushes title/game to Twitch via the broadcaster token if available.
- `scripts/playlist.sh` — playlist sync (`scripts/playlist.sh sync` / default) and legacy daemon control (`skip`/`prev`/`pause`/`resume`/`status`).
- `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/clean.sh` — read-only audit of `playlist/*.webm` vs sibling `.m4a` (which are converted, which still need conversion).

**Reference / data:**
- `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

```
                   ┌────────────┐
                   │    MPD     │  user systemd: mpd.socket + mpd.service
                   └──────┬─────┘
                          │  PipeWire output: target = "mpd_stream"
                          ▼
              ┌────────────────────────┐
              │ null sink "mpd_stream" │  defined in
              └──────┬──────────┬──────┘  ~/.config/pipewire/
                     │          │         pipewire-pulse.conf.d/mpd-stream.conf
        loopback     │          │  monitor
        (so you      │          │  (isolated stream feed —
         hear it)    │          │   no Discord, no desktop bleed)
                     ▼          ▼
              default sink   OBS pulse_output_capture
              (speakers)     "Audio Output Capture → Monitor of MPD-Stream"
                                     │
                                     ▼
                                 Twitch
```

The dedicated null sink is the load-bearing piece: without it, OBS would either pick up *all* desktop audio (Discord, browser, system) or *none*. The loopback module mirrors the null sink to the user's actual default sink so MPD is still audible locally while OBS captures the isolated monitor.

If `pactl list short sinks | grep mpd_stream` returns nothing after login, `pipewire-pulse` didn't load the conf. Try `systemctl --user restart pipewire-pulse.service` and re-check.

## 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`

## 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.
- 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

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.

**Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use:
```bash
flatpak ps --columns=application | grep -qx com.obsproject.Studio && echo running   # canonical
pgrep -x obs                                                                          # also works
```
Do NOT use `pgrep -fa 'flatpak run com.obsproject.Studio'` — it almost never matches (the wrapper exits, the sandbox keeps running) and silently returns "off" while OBS is very much up. False-negatives here have led to JSON edits during live sessions that got clobbered on the next save.

OBS itself maintains `.bak` (previous save) and `.json.v1` (pre-version-migration) sidecars in `basic/scenes/`. Those are not git history — leave them alone unless explicitly recovering.

## 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 "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 the audio sink exists
pactl list short sinks | grep mpd_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.
- On any path error, immediately run `pwd` to confirm location.
- `ssu` is available for git submodule management.
