SCENE_FONT defaults in config.js point at Debian's font layout, which does not exist in the Alpine runtime image; drawtext only rendered via fontconfig fallback. Pin the Alpine paths in the Dockerfile env. Also add LAST_STATE.md (E2E test results + approved plan for the OBS-style panel rework) and the OBS reference screenshot for the redesign. Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| docs | ||
| src | ||
| test | ||
| web | ||
| .dockerignore | ||
| Dockerfile | ||
| metadata.json | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
multistreaming
Self-hosted live production dashboard: ingest RTMP feeds (one per person from OBS) and restream them to one or more streaming platforms — with shared rooms, role-based collaboration, a zero-knowledge key vault, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing).
- Panel: React + TypeScript + shadcn/ui (Tailwind v4) at
http://<host>:8080. - Ingest: RTMP at
rtmp://<host>:1935/live/<streamKey>. - Fan-out: FFmpeg,
-c copyremux by default; re-encode only for composed scenes.
1. Features
- Per-person feeds — every streamer publishes to their own ingest URL with a unique stream key.
- Rooms & roles — share a production with
owner(full control),editor(edits production, never sees keys), andstreamer(own accounts + start streaming) roles via single-use invite links. - Pluggable providers — Twitch, YouTube, Kick, and a custom RTMP escape hatch, extensible in one file.
- Zero-knowledge vault — stream keys are encrypted in the browser (AES-GCM under a PBKDF2-wrapped vault key); the server stores only ciphertext and grants keys transiently, in memory, when a streamer clicks Start streaming.
- Scenes & composition — named scenes with grid or picture-in-picture layouts, text and image overlays, and per-output audio routing. Activating a scene switches the room from passthrough to a composed program.
2. Architecture
OBS ──RTMP──▶ node-media-server (ingest, :1935)
│ publish/unpublish events
▼
index.js ── reconcileAll()
│
┌───────────┴────────────┐
▼ ▼
passthrough scene (composed)
fanout.js (engine) compose.js → fanout.js
ffmpeg -c copy ffmpeg -filter_complex (xstack/overlay/drawtext)
│ │
└───────────┬────────────┘
▼
platform push URLs (Twitch/YouTube/Kick/custom, resolved from in-memory grants)
| Component | Path | Role |
|---|---|---|
src/index.js |
orchestrator | wires config/store/fanout/server; tracks live feeds; reconcileAll() |
src/server.js |
REST API | Express routes, role gating, /api/state |
src/store.js |
persistence | atomic JSON store for users/vaults/rooms/memberships/invites/accounts/feeds/outputs/scenes/sceneOutputs/images |
src/fanout.js |
process engine | generic unit-of-work engine: spawn/kill/restart/reconcile FFmpeg children |
src/compose.js |
composition | builds FFmpeg filter graphs + argv for scenes |
src/vault.js |
crypto | zero-knowledge vault enroll/unlock/recover (WebCrypto) |
src/grants.js |
grants | in-memory, TTL-bounded plaintext key grants |
src/auth.js / src/oidc.js |
auth | sessions + local scrypt / OIDC PKCE |
src/providers.js |
providers | pluggable platform definitions |
src/config.js |
config | environment → runtime config |
web/src/ |
frontend | React/shadcn panel |
3. Directory layout
multistreaming/
├── src/ # CommonJS backend
├── web/ # React + TS + shadcn frontend (src/, built to web/dist)
├── docs/SCENES.md # scene & composition design contract
├── Dockerfile # multi-stage: web build → backend deps → runtime (node + ffmpeg)
├── metadata.json # catalog metadata (version, compose, env)
└── package.json
4. Configuration
Environment variables (see also metadata.json for the UI descriptions):
| Variable | Default | Purpose |
|---|---|---|
AUTH_MODE |
oidc |
oidc (Authelia) or local (dev) |
OIDC_ISSUER |
— | Authelia root URL (OIDC discovery) |
OIDC_CLIENT_ID |
multistreaming |
OIDC client id |
OIDC_REDIRECT_URI |
— | must match Authelia redirect_uris |
SESSION_SECRET |
auto | HMAC secret for session cookies (set it to a stable random value) |
PUBLIC_HOST |
'' |
host shown in copy-paste ingest URLs |
GRANT_TTL_MS |
21600000 |
plaintext-key grant lifetime (6 h) |
RTMP_PORT |
1935 |
RTMP ingest port |
HTTP_PORT |
8080 |
panel + API port |
DATA_DIR |
./data |
persisted state + uploads/ |
FFMPEG_PATH |
ffmpeg |
ffmpeg binary |
SCENE_WIDTH / SCENE_HEIGHT |
1920 / 1080 |
composed canvas |
SCENE_FPS |
30 |
composed frame rate |
SCENE_VIDEO_BITRATE |
4500k |
composed video bitrate |
SCENE_AUDIO_BITRATE |
160k |
composed audio bitrate |
SCENE_FONT |
DejaVu Sans | drawtext font path (installed in the image) |
SCENE_FONT_BOLD |
DejaVu Sans Bold | bold drawtext font path |
SCENE_MAX_IMAGE_BYTES |
1048576 |
image overlay upload cap (1 MiB) |
5. Running
Build and run with the repository's catalog tooling (metadata.json provides the compose service) or directly:
docker build -t multistreaming .
docker run -p 1935:1935 -p 8080:8080 \
-v multistreaming_data:/data \
-e AUTH_MODE=oidc \
-e OIDC_ISSUER=https://auth.example.com \
-e OIDC_REDIRECT_URI=https://streaming.example.com/api/auth/oidc/callback \
-e SESSION_SECRET=… \
multistreaming
- Panel:
http://<host>:8080(put it behind nginx-proxy-manager for TLS). - Ingest:
rtmp://<host>:1935/live/<streamKey>— RTMP bypasses the HTTP proxy, so keep port 1935 on a trusted network.
6. Ingest URL model
Each feed has a unique streamKey. Point OBS at:
rtmp://<PUBLIC_HOST>:1935/live/<streamKey>
(PUBLIC_HOST only affects the copy-paste URL shown in the UI; ingest always
hits the container's port 1935.) The live app name is fixed (ingestApp).
When a feed publishes, the orchestrator fans it out to its enabled outputs (or,
if a scene is active, feeds the composed program).
7. Providers
Providers are defined in src/providers.js. Each extends the Provider base
class and implements:
fields— the fields the UI asks the user for (name,label,secret?,required?,placeholder?,help?),defaultUrl— the platform's RTMP server URL,pushUrl(config)— how the key is appended (default:url + "/" + key),describe(config)/validate(config)— copy-safe summary + validation.
To add a provider, add a derived class and register it in PROVIDERS:
class NewPlatformProvider extends Provider {
constructor() {
super({
id: "newplatform",
name: "New Platform",
defaultUrl: "rtmp://ingest.newplatform.example/live",
fields: [{ name: "key", label: "Stream key", secret: true, required: true }],
});
}
}
// …then push `new NewPlatformProvider()` into PROVIDERS.
The custom provider is a free-form RTMP endpoint for anything not explicitly
supported.
8. Scenes & composition
A scene is a named composition for a room (see docs/SCENES.md for the full
contract):
- Layouts —
grid(2–4 columns, up to 6 slots) orpip(main + a picture-in-picture window). Each slot assigns a feed (or stays empty). Only live feeds are laid out; empty/offline slots are skipped and a single live feed is promoted to fullscreen. - Overlays — text (position, size, color, bold) and images (position + size, ≤ 1 MiB, PNG/JPEG/WebP).
- Audio routing — each scene output (destination) chooses
program(mixed audio of all live feeds),silent, or a specificfeed's audio. - Activation — activating a scene switches the room into composed mode (per-feed passthrough pauses); deactivating restores passthrough.
Composed video is re-encoded (H.264 + AAC) per destination so each output can
carry different audio. The FFmpeg filter graph is generated entirely by
src/compose.js; no scene data ever stores a stream key.
9. Security
See SECURITY.md for the full threat model. Highlights:
- Stream keys are encrypted in the browser; the server persists only
secretCiphertext+serverWrapped. - Plaintext keys exist server-side only as short-lived, in-memory grants.
- FFmpeg logs redact destination URLs; overlay text cannot inject FFmpeg arguments; image uploads are size/type-limited.
- Roles and
maskAccountensure editors/streamers never see others' keys.
The honest limit: the machine that pushes a stream must hold the key at push time — if the host is compromised during a stream, live keys are readable.
10. Development
No runtime tests are run by design; verification is static.
# backend syntax check
node --check src/index.js && node --check src/fanout.js && \
node --check src/server.js && node --check src/store.js && \
node --check src/compose.js && node --check src/config.js
# frontend typecheck + build
cd web && npm install && npm run build
npm run build runs tsc -b && vite build and emits web/dist, which the
backend serves at /. A npm run dev Vite server may be used for manual web
inspection during development.