Compare commits

..

9 Commits

Author SHA1 Message Date
3fcb8fbe99 Phase 12: frame-accurate timecode via an overscan band — vgstrip encoder/decoder, sweep writes tmcd + creation_time + sidecar, dumpd /time, Caddy /time routes, MediaMTX microsecond names, /dock/clock/ page, dock band setup via obs-websocket, Go Live band composite + toggle, portal crops the band
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:07:56 -05:00
89841c3d2a Plan: phase 12 — permanent overscan band with a machine-readable server clock; drift-aware decode
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:00:03 -05:00
f1bd28f8d1 Plan: phase 12 redesigned as a 10 s machine-readable sync burst decoded by the sweep into the timecode track; nothing visible in finals
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:58:20 -05:00
4419cc9d67 Plan: dock manages the OBS clock overlay through obs-websocket
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:56:33 -05:00
57be5bba52 Plan: burned-in server clock on the Go Live (WebRTC) path via canvas compositing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:56:00 -05:00
b782621368 Plan: phase 12 uses the server clock as the only time reference
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:53:40 -05:00
b750bdb71c Plan: phase 12 — timecode track + burned-in clock for frame-accurate multicam
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:53:29 -05:00
a7ad3dc224 STATUS/plan: phases 5-7 and 11 live and verified; refreshed unfinished list
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:48:33 -05:00
5085f8ab55 Docs/apply note: MediaMTX does not hot-reload auth settings; restart between streams after auth changes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:46:25 -05:00
18 changed files with 577 additions and 50 deletions

View File

@@ -180,7 +180,7 @@ Checklist:
- [ ] Medal local filename pattern → add to `ItemTimestamp` (need a sample filename) - [ ] Medal local filename pattern → add to `ItemTimestamp` (need a sample filename)
- [ ] later: serve `dist/` at `/plugins/` via Caddy as a plugin repo; theme CSS - [ ] later: serve `dist/` at `/plugins/` via Caddy as a plugin repo; theme CSS
### Phase 5 — copyparty behind Authentik (built; apply pending) ### Phase 5 — copyparty behind Authentik ✅ live
- [ ] Authentik → Providers → Proxy provider, *forward auth (single application)*, external host - [ ] Authentik → Providers → Proxy provider, *forward auth (single application)*, external host
`https://ovh1.HOST`; Application bound to it; add to the embedded outpost `https://ovh1.HOST`; Application bound to it; add to the embedded outpost
- [ ] Caddy: `forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy; copy_headers X-Authentik-Username X-Authentik-Groups … }` - [ ] Caddy: `forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy; copy_headers X-Authentik-Username X-Authentik-Groups … }`
@@ -194,13 +194,13 @@ Checklist:
- [x] decided: no WebDAV (Discord login only); browser downloads - [x] decided: no WebDAV (Discord login only); browser downloads
- [ ] policy on the Application: only `editors` + `admins` + `streamers` may pass - [ ] policy on the Application: only `editors` + `admins` + `streamers` may pass
### Phase 6 — Syncthing UI + dump behind Authentik (built together with phase 5; apply pending) ### Phase 6 — Syncthing UI + dump behind Authentik ✅ live
- [ ] `/sync/`: forward-auth, policy `admins` only; Syncthing GUI auth can stay as a second layer - [ ] `/sync/`: forward-auth, policy `admins` only; Syncthing GUI auth can stay as a second layer
- [ ] `dumpd.py`: read `X-Authentik-Username` / `X-Authentik-Groups` from Caddy instead of basic auth; - [ ] `dumpd.py`: read `X-Authentik-Username` / `X-Authentik-Groups` from Caddy instead of basic auth;
admin = member of `admins`; drop `dump-users.txt` admin = member of `admins`; drop `dump-users.txt`
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity - [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
### Phase 7 — dock polish + cleanup (built with phase 11; apply pending) ### Phase 7 — dock polish + cleanup ✅ live (dock mints tokens; no stored credentials)
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer; - [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
dock fills OBS settings from it — nobody types a stream password again dock fills OBS settings from it — nobody types a stream password again
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik) - [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)
@@ -263,7 +263,7 @@ Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.
- [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group) - [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group)
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group - [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) (built; apply pending) ### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) ✅ live
Implemented variant: **short-lived HMAC tokens minted by dumpd** for Discord-authenticated members of Implemented variant: **short-lived HMAC tokens minted by dumpd** for Discord-authenticated members of
`streamers` (no Authentik app passwords — "Discord only" decision). Dock and portal mint a token per Go Live; `streamers` (no Authentik app passwords — "Discord only" decision). Dock and portal mint a token per Go Live;
manual setups get it from the dock's accordion. MediaMTX `authMethod: http` → dumpd `/mtx-auth` on 8081 manual setups get it from the dock's accordion. MediaMTX `authMethod: http` → dumpd `/mtx-auth` on 8081
@@ -287,6 +287,38 @@ Discord roles); the publish path must start with the user's own name.
- [ ] dock + README; remove streamers from configure.py; delete `streamers.json` - [ ] dock + README; remove streamers from configure.py; delete `streamers.json`
- [ ] test: OBS with an app password; Go Live with no key; a non-streamer gets denied - [ ] test: OBS with an app password; Go Live with no key; a non-streamer gets denied
### Phase 12 — frame-accurate timecode for multicam via an overscan band (built; deploy pending)
Target: clips from several streamers align to the frame (16.7 ms @60) on a multicam timeline; the game
image is untouched and finals carry nothing. Reference clock = the server; streamer PC clocks are never trusted.
Facts that shape it: within a recording, frame timing is the encoder's capture clock (network lag only shifts
*arrival*, which is measured once); but a PC clock drifts vs the server (10 ppm ≈ 4 frames over 2 h), so a
permanent reference is needed for long sessions, not just a start burst.
- **Overscan band**: the published canvas is the game image plus 16 extra rows at the bottom. The band holds a
machine-readable strip (black/white cells: server time in ms + checksum + framing, cell size scaled to the
canvas) and a small human-readable server clock, **continuously**.
- Go Live: canvas composite = video (W×H) + band → W×(H+16); the `/clock/` logic runs in-page.
- OBS: the dock (obs-websocket) offers "add overscan band": SetVideoSettings canvas/output to H+16 while not
streaming, CreateInput `browser_source` → `/clock/` positioned in the band, toggle/remove. The browser
source shares the dock's cookies (Authentik). Opt-out = coarse timecode only.
- `/clock/` measures its offset to the server clock over HTTPS (±few ms) and shows **server** time.
- **Sweep**: when publishing a stream: extract frames at the start, middle and end; locate + decode the strip;
frame-0 capture time = median(decoded − pts) at the start (frame-accurate) → QuickTime `tmcd` track
(time of day, Eastern, file fps) + `creation_time` (UTC) in the existing lossless remux; a `.tc.json`
sidecar with the samples records drift over the file. No strip → server arrival time (coarse), logged.
Never re-encode, never crop.
- **Viewing**: live portal crops the band with CSS; Jellyfin shows a thin strip at the bottom edge (VHS-style).
Editors export at the game resolution (2560×1440), which drops the band.
- MediaMTX `recordPath` with `%f` (microseconds) for the fallback; plugin `ItemTimestamp` prefers `creation_time`.
- [x] `/clock/` page: offset measurement, strip encoder, readable clock, band layout
- [x] dock: overscan band setup + browser source via obs-websocket; opt-out
- [x] portal Go Live: band composite (toggle); portal viewer: CSS crop of the band
- [x] sweep: `bin/vgstrip.py` (ffmpeg frames → decode → offset + drift), tmcd + creation_time, `<stem>.json` sidecar (read by the plugin)
- [x] MediaMTX `%f`; plugin timestamp update; README editor workflow (multicam: timecode → audio cross-check;
export at 2560×1440)
- rejected: LTC audio (SRT multitrack + per-PC generator); clock inside the picture; cropping at remux (re-encode)
### Option — more clip sources (Medal, Twitch/YouTube/Kick clips, Discord uploads) ### Option — more clip sources (Medal, Twitch/YouTube/Kick clips, Discord uploads)
Today the only clip intake is a local clipper folder synced by Syncthing. Members without that Today the only clip intake is a local clipper folder synced by Syncthing. Members without that

View File

@@ -289,12 +289,36 @@ MediaMTX asks dumpd (`POST /mtx-auth` on 127.0.0.1:8081, reachable only from Doc
connection. Reads/playback are allowed (viewers are gated by Caddy + Authentik). Publishing needs a token connection. Reads/playback are allowed (viewers are gated by Caddy + Authentik). Publishing needs a token
minted by `GET /me/stream-token` behind Authentik for a member of `streamers`/`admins`/`media-admins`; dumpd minted by `GET /me/stream-token` behind Authentik for a member of `streamers`/`admins`/`media-admins`; dumpd
checks the HMAC signature (`/etc/vanguard/stream-token.secret`, generated into `.env` as `STREAM_TOKEN_SECRET`), checks the HMAC signature (`/etc/vanguard/stream-token.secret`, generated into `.env` as `STREAM_TOKEN_SECRET`),
the expiry, and that the path starts with that user's name. Changing this needs **no MediaMTX restart**: the expiry, and that the path starts with that user's name. MediaMTX hot-reloads paths/recording settings
MediaMTX hot-reloads `mediamtx.yml`; `configure.py` reloads Caddy in place and only restarts copyparty. but **not** the auth section: after changing auth, `docker compose restart mediamtx` between streams.
`configure.py` reloads Caddy in place and restarts only copyparty.
## Timecode for multicam editing (frame accurate)
Every published stream carries a QuickTime **timecode track** (`tmcd`, time of day in Eastern, at the file's
frame rate) and a UTC `creation_time`, written by the sweep during the lossless remux, plus a sidecar
`<Title>__<ts>.json` with the exact capture time (the Jellyfin plugin reads it). Resolve and Premiere read the
track natively: drop clips from several streamers on a multicam timeline, sync by timecode.
Where the time comes from, best first:
1. **Overscan band** — a thin strip of extra rows below the picture carrying the *server* clock, encoded
(black/white cells, format in `bin/vgstrip.py`) plus a small readable clock. The server decodes it
(`bin/vgstrip.py`) at the start, middle and end of each recording → the timecode is frame-accurate at the
start and the sidecar records drift over the file. Game image untouched. OBS: dock → *Timecode band* →
"Add band + clock source" (canvas grows by W/160 rows, a locked browser source `Vanguard Clock` →
`/dock/clock/` sits in the band; only while not streaming). Go Live: the "Timecode band" checkbox (default on).
2. **Server arrival time** (no band) — microseconds in the recording filename. Within ~0.1–0.3 s per streamer
(encoder + network delay); coarse but always available.
Viewing: the live portal crops the band; Jellyfin shows it as a thin strip at the bottom (VHS-style);
**editors export at the game resolution (e.g. 2560×1440) which drops the band** — finals carry nothing.
Cross-check when it matters: everyone is in the same voice chat, so audio-waveform sync is sample-accurate.
Why continuous and not a start burst: a PC clock drifts vs the server (10 ppm ≈ 4 frames over 2 h).
## Layout on the server ## Layout on the server
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty) /srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)
/srv/media/streams/<user>/<Title>__<ts>.json capture time (UTC ms), timecode, source (strip|arrival), drift
/srv/media/streams/<user>/<Title>__<ts>.mp4 finished stream, one flat MP4 per recording, directly in /srv/media/streams/<user>/<Title>__<ts>.mp4 finished stream, one flat MP4 per recording, directly in
the streamer's folder (several files inside one title the streamer's folder (several files inside one title
folder would be ONE Jellyfin episode with "versions") folder would be ONE Jellyfin episode with "versions")
@@ -316,7 +340,7 @@ upscaled; aspect kept).
| input | action | | input | action |
|-------|--------| |-------|--------|
| already compliant | nothing | | already compliant | nothing |
| MediaMTX recording in `<Title>/.live/` (fragmented MP4) | once idle 3 min: lossless remux to `<user>/<Title>__<ts>.mp4`, `.live` source and empty title folder removed | | MediaMTX recording in `<Title>/.live/` (fragmented MP4) | once idle 3 min: decode the timecode band (or use arrival time), lossless remux to `<user>/<Title>__<ts>.mp4` with a `tmcd` track + `creation_time`, write the `.json` sidecar, remove `.live` and the empty title folder |
| in a Syncthing folder (`.stfolder` marker) | compliant / index-only: untouched; otherwise convert → `name.mp4`, add raw to `.stlocal`, delete raw (one copy) | | in a Syncthing folder (`.stfolder` marker) | compliant / index-only: untouched; otherwise convert → `name.mp4`, add raw to `.stlocal`, delete raw (one copy) |
| HEVC / 10-bit / VFR / MKV / >1440p / >60fps / non-AAC | full-res re-encode (x264 veryfast CRF 18) → `name.mp4`; source kept in `.src/` | | HEVC / 10-bit / VFR / MKV / >1440p / >60fps / non-AAC | full-res re-encode (x264 veryfast CRF 18) → `name.mp4`; source kept in `.src/` |
| ffmpeg fails or duration mismatch | `name.normalize-failed.log` written, file skipped until that log is deleted | | ffmpeg fails or duration mismatch | `name.normalize-failed.log` written, file skipped until that log is deleted |

View File

@@ -1,6 +1,6 @@
# Vanguard stack — what runs, what people do, what's unfinished # Vanguard stack — what runs, what people do, what's unfinished
Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-10-31. Living document. Updated whenever something is deployed or changes behaviour. Last update: 2026-10-31 (evening).
Host: `ovh1.dumpstervigilantes.rip` (OVH SYS-1, Debian 13). Bundle: `~/vanguard-stream` on the box. Host: `ovh1.dumpstervigilantes.rip` (OVH SYS-1, Debian 13). Bundle: `~/vanguard-stream` on the box.
Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea (`ssh://git@git.dumpstervigilantes.rip:2222/techy/Media-Server-Setup.git`). Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea (`ssh://git@git.dumpstervigilantes.rip:2222/techy/Media-Server-Setup.git`).
@@ -11,7 +11,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Continuous | OBS streams record into a hidden `streams/<user>/<Title>/.live/` folder, invisible to Jellyfin and copyparty until finished | MediaMTX | | Continuous | OBS streams record into a hidden `streams/<user>/<Title>/.live/` folder, invisible to Jellyfin and copyparty until finished | MediaMTX |
| Continuous | Gamers' clip folders mirror onto the server (video files only, their structure untouched, receive-only) | Syncthing | | Continuous | Gamers' clip folders mirror onto the server (video files only, their structure untouched, receive-only) | Syncthing |
| Continuous | New recordings and clips are announced in a Discord channel with the streamer's name and a frame grab | Jellyfin plugin (needs bot token + channel in its config) | | Continuous | New recordings and clips are announced in a Discord channel with the streamer's name and a frame grab | Jellyfin plugin (needs bot token + channel in its config) |
| Every 5 min | Finished files are checked. Streams idle ≥3 min: lossless remux out of `<Title>/.live/` to `<user>/<Title>__<ts>.mp4`, one file per recording (this is when they appear in Jellyfin/copyparty and the Discord notice fires). Non-compliant clips: converted to the editing standard, raw removed, one copy per clip | normalize sweep (systemd timer) | | Every 5 min | Finished files are checked. Streams idle ≥3 min: timecode band decoded (server clock) or arrival time used, lossless remux out of `<Title>/.live/` to `<user>/<Title>__<ts>.mp4` with a frame-accurate `tmcd` track + sidecar, one file per recording (this is when they appear in Jellyfin/copyparty and the Discord notice fires). Non-compliant clips: converted to the editing standard, raw removed, one copy per clip | normalize sweep (systemd timer) |
| Every 5 min | Discord scheduled events snapshotted into a history so recordings can be matched to their session | Jellyfin plugin | | Every 5 min | Discord scheduled events snapshotted into a history so recordings can be matched to their session | Jellyfin plugin |
| Every 30 min + startup | Every non-admin Jellyfin user set to direct-play-only (no hardware encoder on this box) | Jellyfin plugin | | Every 30 min + startup | Every non-admin Jellyfin user set to direct-play-only (no hardware encoder on this box) | Jellyfin plugin |
| Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin | | Daily 04:10 ET | Recordings roll between weekday → week → month → year seasons as the calendar moves; no files change | Jellyfin plugin |
@@ -25,11 +25,13 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Who | Activity | Where | Login | | Who | Activity | Where | Login |
|---|---|---|---| |---|---|---|---|
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock (once); dock mints a 12 h token per Go Live — **no passwords** (pending apply) | | Streamer | One-time: dock → Timecode band → "Add band + clock source" (OBS) or leave the Go Live checkbox on — gives editors frame-accurate timecode | OBS dock / Go Live | — **pending deploy** |
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock; dock mints a 12 h token per Go Live — **no passwords, verified live** |
| Gamer | Nothing; clips save where they always did | their clipper + Syncthing, paired once | — | | Gamer | Nothing; clips save where they always did | their clipper + Syncthing, paired once | — |
| Editor | Browse by streamer/title, scrub in browser, download originals, upload finals | copyparty `https://ovh1…/` | Discord (Authentik); rights by group `editors` / `media-admins` — **pending apply** | | Editor | Multicam: drop clips on a timeline, sync by the embedded timecode track (frame-accurate when the band was on), cross-check with voice audio; export at the game resolution to drop the band | Resolve / Premiere | — |
| Editor | Browse by streamer/title, scrub in browser, download originals (zip/tar for folders), upload finals | copyparty `https://ovh1…/` | Discord (Authentik); rights by group `editors` / `media-admins` |
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) | | Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
| Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik, domain-level) — live; viewer side not yet exercised during a stream | | Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik, domain-level) — **live, shows a real stream** |
| Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) | | Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) |
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) | | Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists | | Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
@@ -43,10 +45,11 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Service | Role | State | | Service | Role | State |
|---|---|---| |---|---|---|
| MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens) | live; auth switch **pending apply** (hot-reload, no restart) | | MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens via `/mtx-auth`) | live, token auth verified |
| copyparty | browse/scrub/download/WebDAV/finals upload | behind Authentik, group permissions — **built, pending apply** (currently local accounts) | | copyparty | browse/scrub/download/finals upload | behind Authentik, group permissions (`editors`, `media-admins`), Discord-only, no WebDAV | live |
| dumpd + dock | dump-tonight endpoint, OBS dock page | identity from Authentik headers — **built, pending apply** | | dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`; OBS dock with timecode-band setup | identity from Authentik headers | live; band/timecode **pending deploy** |
| Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins` — **built, pending apply** | | Timecode | `bin/vgstrip.py` decoder, `tmcd` + sidecar in the sweep, `/dock/clock/` strip page, Go Live band composite, portal crop | server clock is the reference | **built, pending deploy** |
| Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins` | live |
| normalize sweep | editing standard, one copy per clip | live | | normalize sweep | editing standard, one copy per clip | live |
| nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live | | nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live |
| Caddy | TLS + routing for all hostnames | live | | Caddy | TLS + routing for all hostnames | live |
@@ -62,21 +65,18 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
## Unfinished, in rough priority ## Unfinished, in rough priority
1. **MediaMTX container recreate** for `TZ=America/New_York` (filenames were UTC-stamped) and `moq: no` — `docker compose up -d --force-recreate mediamtx` after the compose upload. 0. **Deploy phase 12** (timecode): vgstrip + sweep + dumpd `/time` + configure (Caddy `/time` routes, MediaMTX `%f`) + dock/clock + portal. Then a streamer adds the band and we check a recording's `tmcd` and sidecar.
1b. Authentik `discord-roles-to-groups` policy + `bot-users` group (`bot` CNAME now correct). 1. Authentik: `discord-roles-to-groups` policy so `streamers`/`editors`/`media-admins`/`bot-users` follow Discord roles (`bot-users` group needed for the bot host). Set the provider's Invalidation flow to `default-provider-invalidation-flow` so every logout forces a full re-login.
1c. Confirm plugin 0.1.2 installed and runner registered. 2. Confirm plugin 0.1.3 under Jellyfin → Plugins and the Gitea runner registered.
1d. Watch a real stream from a second browser: corner chip should say `webrtc`; confirm WarDogs audio came through. 3. Portal viewer during a real stream: corner chip `webrtc`; Windows Go Live with WarDogs audio (first Windows streamer).
2. **Register the runner** (`./registerGiteaRunner.sh`). 4. Gitea login via Authentik (plan phase 10).
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty. 5. Housekeeping: pin `AUTHENTIK_TAG`, rotate the Discord client secret seen in a screenshot, nightly Postgres dump, Caddyfile formatting warning.
4. **Apply phase 5+6** (copyparty, dock, dump, /sync behind Authentik; group permissions). Authentik side: group `media-admins` with techy in it. Decision: Discord login only → no WebDAV; browser downloads (zip/tar) instead. 6. Medal filename pattern in the plugin (need a sample filename).
5. **Apply phase 7+11** (token publish auth): `configure.py` apply (MediaMTX hot-reloads; no stream interruption), new dumpd + unit, firewall rule for 8081 from Docker, dock upload, portal deploy. Then every streamer must be in the Authentik `streamers` group. 7. Home archive puller on the home box.
6. **Gitea login via Authentik** (OIDC source, auto-register, `admins` → Gitea admin). 8. Plugin repository served by Caddy so Jellyfin updates from its catalog.
7. **Housekeeping**: pin `AUTHENTIK_TAG` in `.env`, rotate the Discord client secret that was in a screenshot, nightly Postgres dump, Jellyfin login branding (Discord button first), check a non-admin Discord user's Jellyfin view. 9. Clip submissions (belongs to the bot). Bot code from the other dev.
8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename. 10. Authentik blueprint from `.env` (Discord source, groups, policies, provider) for reproducible installs.
9. **Home archive puller** on the home box. 11. SYS-3 when transcoding or sweep speed hurts.
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog.
11. **Clip submissions** (Medal/Twitch/YouTube links, Discord uploads) — belongs to the bot.
13. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
## Verified behaviours worth remembering ## Verified behaviours worth remembering
- **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only. - **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only.
@@ -85,6 +85,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
- Several videos inside one non-season folder = ONE Jellyfin episode with "versions". Streams are therefore published flat as `<Title>__<ts>.mp4` in the streamer's folder (plugin 0.1.3 parses the title from the name). Four or five helmet cams a night each get their own episode. - Several videos inside one non-season folder = ONE Jellyfin episode with "versions". Streams are therefore published flat as `<Title>__<ts>.mp4` in the streamer's folder (plugin 0.1.3 parses the title from the name). Four or five helmet cams a night each get their own episode.
- The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now. - The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now.
- A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic. - A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic.
- MediaMTX hot-reloads `paths`/recording settings but NOT `authMethod`/auth settings: those need a container restart (between streams).
- MediaMTX stamps recording filenames in its container clock: without `TZ` it is UTC and the plugin reads names as Eastern (4 h off). `TZ=America/New_York` is set on the container. - MediaMTX stamps recording filenames in its container clock: without `TZ` it is UTC and the plugin reads names as Eastern (4 h off). `TZ=America/New_York` is set on the container.
- MediaMTX 1.21 refuses to start without `webrtcAdditionalHosts` or `webrtcIPsFromInterfaces`; configure.py writes the public host in. - MediaMTX 1.21 refuses to start without `webrtcAdditionalHosts` or `webrtcIPsFromInterfaces`; configure.py writes the public host in.
- Jellyfin honours provider-supplied season numbers on a Shows library (day seasons appeared). Next check: Monday 04:10 ET should fold them into a week season. - Jellyfin honours provider-supplied season numbers on a Shows library (day seasons appeared). Next check: Monday 04:10 ET should fold them into a week season.

View File

@@ -161,6 +161,9 @@ class H(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if self.server.server_port == AUTH_LISTEN[1]: if self.server.server_port == AUTH_LISTEN[1]:
return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only
if self.path.startswith("/time"):
# server clock for the timecode strip (the /clock/ page and Go Live measure their offset against this)
return self._json(200, {"now_ms": int(_time.time() * 1000)})
if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"): if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
# Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers. # Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
who, _ = identity(self.headers) who, _ = identity(self.headers)

View File

@@ -104,9 +104,32 @@ while IFS= read -r -d '' src; do
fi fi
tmp="$out.part" tmp="$out.part"
log "$verdict $src ($reason)" log "$verdict $src ($reason)"
tcargs=(); tcjson=""
if (( live )); then
# timecode: decode the overscan strip (server clock) or fall back to the server arrival time in the filename
stem="$(basename "${src%.*}")"
arrival_ms=$(python3 - "$stem" <<'PYEOF'
import re, sys
from datetime import datetime
from zoneinfo import ZoneInfo
m = re.search(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})(?:-(\d{1,6}))?", sys.argv[1])
if not m: sys.exit(0)
y, mo, d, h, mi, s, us = m.groups()
us = int((us or "0").ljust(6, "0"))
t = datetime(int(y), int(mo), int(d), int(h), int(mi), int(s), us, tzinfo=ZoneInfo("America/New_York"))
print(int(t.timestamp() * 1000))
PYEOF
)
eval "$(python3 /usr/local/bin/vgstrip.py plan "$src" "${arrival_ms:-}" 2>/dev/null || true)"
if [[ -n ${TC_TIMECODE:-} ]]; then
tcargs=(-timecode "$TC_TIMECODE" -metadata "creation_time=$TC_CREATION" -write_tmcd 1)
tcjson="${TC_JSON:-}"
log "tc $src source=$TC_SOURCE timecode=$TC_TIMECODE"
fi
fi
if [[ $verdict == remux ]]; then if [[ $verdict == remux ]]; then
nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \ nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
-map 0 -c copy -movflags +faststart -f mp4 "$tmp" -map 0 -c copy "${tcargs[@]}" -movflags +faststart -f mp4 "$tmp"
else else
# scale down into the cap (never up), keep aspect, even dimensions; cap fps; CFR; all audio -> AAC # scale down into the cap (never up), keep aspect, even dimensions; cap fps; CFR; all audio -> AAC
vf="scale='min(iw,$MAX_W)':'min(ih,$MAX_H)':force_original_aspect_ratio=decrease:force_divisible_by=2" vf="scale='min(iw,$MAX_W)':'min(ih,$MAX_H)':force_original_aspect_ratio=decrease:force_divisible_by=2"
@@ -117,13 +140,16 @@ while IFS= read -r -d '' src; do
-c:v libx264 -preset veryfast -crf 18 -maxrate 30M -bufsize 60M -pix_fmt yuv420p -profile:v high \ -c:v libx264 -preset veryfast -crf 18 -maxrate 30M -bufsize 60M -pix_fmt yuv420p -profile:v high \
-vf "$vf" -fps_mode cfr "${fpsargs[@]}" \ -vf "$vf" -fps_mode cfr "${fpsargs[@]}" \
-c:a aac -b:a 160k \ -c:a aac -b:a 160k \
-movflags +faststart -f mp4 "$tmp" "${tcargs[@]}" -movflags +faststart -f mp4 "$tmp"
fi fi
rc=$? rc=$?
d1=$(dur "$src"); d2=$(dur "$tmp") d1=$(dur "$src"); d2=$(dur "$tmp")
if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then
if (( live )); then if (( live )); then
mv -f "$tmp" "$out"; chown 1000:1000 "$out" 2>/dev/null mv -f "$tmp" "$out"; chown 1000:1000 "$out" 2>/dev/null
if [[ -n $tcjson ]]; then # sidecar: exact capture time (read by the Jellyfin plugin) + drift samples
printf '%s\n' "$tcjson" > "${out%.*}.json"; chown 1000:1000 "${out%.*}.json" 2>/dev/null
fi
rm -f "$src"; rmdir "$(dirname "$src")" "$titledir" 2>/dev/null || true # drop .live and the empty title folder rm -f "$src"; rmdir "$(dirname "$src")" "$titledir" 2>/dev/null || true # drop .live and the empty title folder
log "done $out"; continue log "done $out"; continue
elif (( synced )); then elif (( synced )); then

160
bin/vgstrip.py Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Vanguard timecode strip — shared format (encoder for tests, decoder for the sweep).
The published canvas is the game image plus an "overscan band" of extra rows at the bottom. Everything is
expressed relative to the canvas WIDTH so any uniform rescale keeps the geometry:
cell = W / 160 (16 px at 2560, 12 px at 1920)
band = round_even(cell) rows at the bottom of the frame
strip = 64 cells starting at x = cell, vertically centred in the band:
[1,0,1,0] framing · 48-bit server time in ms (MSB first) · 8-bit CRC-8 (poly 0x07) · [0,1,0,1] framing
1 = white, 0 = black
text = human-readable server clock to the right of the strip (not decoded)
Decode: sample the centre of each cell on the row y = H - band/2; threshold at the midpoint of the framing
cells' brightness; verify framing + CRC. The decoded value is SERVER time at render; frame-0 capture time =
median(decoded_k - pts_k) over several frames.
"""
import sys, json, struct, subprocess, statistics
CELLS = 64
FRAME_A = [1, 0, 1, 0]
FRAME_B = [0, 1, 0, 1]
def crc8(data: bytes, poly=0x07) -> int:
c = 0
for b in data:
c ^= b
for _ in range(8):
c = ((c << 1) ^ poly) & 0xFF if c & 0x80 else (c << 1) & 0xFF
return c
def encode_bits(ms: int):
payload = ms.to_bytes(6, "big")
bits = [(payload[i // 8] >> (7 - i % 8)) & 1 for i in range(48)]
c = crc8(payload)
bits += [(c >> (7 - i)) & 1 for i in range(8)]
return FRAME_A + bits + FRAME_B
def decode_bits(bits):
if bits[:4] != FRAME_A or bits[-4:] != FRAME_B: return None
body = bits[4:-4]
payload = bytes(int("".join(map(str, body[i:i + 8])), 2) for i in range(0, 48, 8))
c = int("".join(map(str, body[48:56])), 2)
if crc8(payload) != c: return None
return int.from_bytes(payload, "big")
def geometry(w):
cell = w / 160.0
band = int(round(cell)); band += band % 2
return cell, band
def cell_centres(w):
cell, band = geometry(w)
return [int(round(cell * (1 + i + 0.5))) for i in range(CELLS)], band
# ------------------------------------------------------------------ decoder over a raw gray band
def decode_band_row(row: bytes, w: int):
"""row = one grayscale row (w bytes) taken from the middle of the band."""
xs, _ = cell_centres(w)
vals = []
for x in xs:
lo, hi = max(0, x - 1), min(w, x + 2)
vals.append(sum(row[lo:hi]) / max(1, hi - lo))
fr = vals[:4] + vals[-4:]
thr = (min(fr) + max(fr)) / 2.0
if max(fr) - min(fr) < 40: return None # no contrast: strip absent
return decode_bits([1 if v > thr else 0 for v in vals])
def probe(path):
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=width,height,avg_frame_rate:format=duration", "-of", "json", path],
capture_output=True, text=True, check=True).stdout
j = json.loads(out); st = j["streams"][0]
num, den = st["avg_frame_rate"].split("/"); fps = float(num) / float(den or 1)
return int(st["width"]), int(st["height"]), fps, float(j["format"]["duration"])
def sample_window(path, w, h, fps, start_s, dur_s):
"""Decode every frame in [start, start+dur). Returns [(pts_s, server_ms)]."""
import re
_, band = geometry(w)
y0 = h - band
# -copyts keeps the file's own timestamps; showinfo prints the exact pts of every emitted frame
cmd = ["ffmpeg", "-nostdin", "-v", "info", "-copyts", "-ss", f"{start_s:.3f}", "-t", f"{dur_s:.3f}", "-i", path,
"-an", "-vf", f"crop={w}:{band}:0:{y0},format=gray,showinfo", "-f", "rawvideo", "-"]
r = subprocess.run(cmd, capture_output=True, check=True)
raw = r.stdout
pts = [float(m) for m in re.findall(r"\bpts_time:\s*([0-9.]+)", r.stderr.decode(errors="replace"))]
frame_len = w * band
res = []
for k in range(min(len(raw) // frame_len, len(pts))):
row = raw[k * frame_len + (band // 2) * w: k * frame_len + (band // 2) * w + w]
ms = decode_band_row(row, w)
if ms is not None:
res.append((pts[k], ms))
return res
def first_pts(path):
"""pts of the first video frame (fMP4 from x264 with B-frames starts ~2 frames after 0)."""
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts_time",
"-read_intervals", "%+#1", "-of", "csv=p=0", path], capture_output=True, text=True).stdout.strip()
try: return float(out.splitlines()[0].strip(",").split(",")[0])
except Exception: return 0.0
def analyse(path, win_s=4.0):
w, h, fps, dur = probe(path)
p0 = first_pts(path) # timecode start is the FIRST FRAME, not pts 0
windows = [0.0]
if dur > 60: windows.append(max(0.0, dur / 2 - win_s / 2))
if dur > 20: windows.append(max(0.0, dur - win_s - 0.5))
samples = []
for ws in windows:
pts = sample_window(path, w, h, fps, ws, win_s)
if pts:
offs = [ms - (pts_s - p0) * 1000.0 for pts_s, ms in pts]
samples.append({"at_s": round(ws, 3), "frames": len(pts), "t0_ms": int(statistics.median(offs))})
if not samples:
return {"source": "none", "fps": fps, "width": w, "height": h, "duration_s": dur}
t0 = samples[0]["t0_ms"]
drift = samples[-1]["t0_ms"] - t0 if len(samples) > 1 else 0
return {"source": "strip", "t0_ms": t0, "drift_ms": drift, "fps": fps, "width": w, "height": h,
"duration_s": dur, "samples": samples}
def timecode_string(t0_ms, fps, tz="America/New_York"):
"""Time-of-day timecode for the tmcd track: HH:MM:SS:FF (drop-frame ';' for 29.97/59.94)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
local = datetime.fromtimestamp(t0_ms / 1000.0, timezone.utc).astimezone(ZoneInfo(tz))
frac = (t0_ms % 1000) / 1000.0
nominal = round(fps)
ff = min(nominal - 1, int(frac * fps))
sep = ";" if abs(fps - nominal) > 0.01 else ":"
return f"{local:%H:%M:%S}{sep}{ff:02d}", local
def plan(path, arrival_ms=None, tz="America/New_York"):
"""Decide the timecode for a recording: strip if present, else server arrival time. Prints shell vars."""
from datetime import datetime, timezone
r = analyse(path)
if r.get("source") == "strip":
t0 = r["t0_ms"]; src = "strip"
elif arrival_ms:
t0 = int(arrival_ms); src = "arrival"
else:
t0 = None; src = "none"
out = {"source": src, "fps": r.get("fps"), "drift_ms": r.get("drift_ms", 0), "samples": r.get("samples", [])}
if t0 is not None:
tc, local = timecode_string(t0, r.get("fps") or 60.0, tz)
utc = datetime.fromtimestamp(t0 / 1000.0, timezone.utc)
out.update({"t0_ms": t0, "timestamp": utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t0 % 1000:03d}Z",
"timecode": tc, "timecode_tz": tz, "local_start": local.isoformat()})
print(f"TC_TIMECODE={tc}")
print(f"TC_CREATION={utc.strftime('%Y-%m-%dT%H:%M:%S')}.{t0 % 1000:03d}Z")
print(f"TC_SOURCE={src}")
print("TC_JSON=" + json.dumps(json.dumps(out))) # double-encoded: shell-safe single token
return out
if __name__ == "__main__":
if len(sys.argv) < 2: print(__doc__); sys.exit(2)
if sys.argv[1] == "plan":
plan(sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else None); sys.exit(0)
print(json.dumps(analyse(sys.argv[1]), indent=2))

View File

@@ -9,14 +9,16 @@ for f in install.sh addCommunityMediaServer.sh installJellyfinPlugin.sh register
[[ -f $f ]] || continue [[ -f $f ]] || continue
bash -n "$f" && say "$f" ok || { say "$f" "BASH SYNTAX"; fail=1; } bash -n "$f" && say "$f" ok || { say "$f" "BASH SYNTAX"; fail=1; }
done done
for f in configure.py bin/dumpd.py; do for f in configure.py bin/dumpd.py bin/vgstrip.py; do
python3 -m py_compile "$f" && say "$f" ok || { say "$f" "PYTHON SYNTAX"; fail=1; } python3 -m py_compile "$f" && say "$f" ok || { say "$f" "PYTHON SYNTAX"; fail=1; }
done done
rm -rf __pycache__ bin/__pycache__ rm -rf __pycache__ bin/__pycache__
python3 -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" docker-compose.yml mediamtx.example.yml && say "yaml" ok || { say "yaml" "INVALID"; fail=1; } python3 -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" docker-compose.yml mediamtx.example.yml && say "yaml" ok || { say "yaml" "INVALID"; fail=1; }
node -e " node -e "
const fs=require('fs');const html=fs.readFileSync('dock/index.html','utf8'); const fs=require('fs');
for (const m of html.matchAll(/<script(?:[^>]*)>([\s\S]*?)<\/script>/g)) new Function(m[1]);" && say "dock/index.html JS" ok || { say "dock/index.html JS" "SYNTAX ERROR"; fail=1; } for (const f of ['dock/index.html','dock/clock/index.html']) { const html=fs.readFileSync(f,'utf8'); for (const m of html.matchAll(/<script(?:[^>]*)>([\s\S]*?)<\/script>/g)) if (m[1].trim()) new Function(m[1]); }
new Function(fs.readFileSync('dock/clock/vgclock.js','utf8'));" && say "dock + clock JS" ok || { say "dock + clock JS" "SYNTAX ERROR"; fail=1; }
diff <(sed '$d' live/src/vgclock.js | sed 's/})(typeof window.*/})(window);/') dock/clock/vgclock.js >/dev/null && say "vgclock.js in sync (dock/portal)" ok || say "vgclock.js in sync (dock/portal)" "DIFFERS — copy dock/clock/vgclock.js to live/src and re-add the export line"
# generated Caddyfile / mediamtx / copyparty from a dry configure run must at least be produced + parse # generated Caddyfile / mediamtx / copyparty from a dry configure run must at least be produced + parse
T=$(mktemp -d); cp configure.py "$T/"; ( cd "$T" && VG_DRY=1 python3 -c " T=$(mktemp -d); cp configure.py "$T/"; ( cd "$T" && VG_DRY=1 python3 -c "
import configure as c, yaml import configure as c, yaml

View File

@@ -167,7 +167,10 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
{live_host} {{ {live_host} {{
encode zstd gzip encode zstd gzip
{gate(env.get("LIVE_REQUIRE_GROUP", ""))} handle /me/* {{ {gate(env.get("LIVE_REQUIRE_GROUP", ""))} handle /me/* {{
reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-key for the logged-in streamer (Go Live) reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-token for the logged-in streamer (Go Live)
}}
handle /time {{
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip
}} }}
handle_path /api/* {{ handle_path /api/* {{
reverse_proxy 127.0.0.1:9997 reverse_proxy 127.0.0.1:9997
@@ -256,6 +259,9 @@ name: {{http.request.header.X-Authentik-Name}}"
handle /me/* {{ handle /me/* {{
reverse_proxy 127.0.0.1:8080 reverse_proxy 127.0.0.1:8080
}} }}
handle /time {{
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip
}}
# Syncthing admin UI: Authentik + group {sync_group or 'any'}; Syncthing's own GUI login stays as a second layer # Syncthing admin UI: Authentik + group {sync_group or 'any'}; Syncthing's own GUI login stays as a second layer
{sync_gate} redir /sync /sync/ 308 {sync_gate} redir /sync /sync/ 308
@@ -358,7 +364,7 @@ srtAddress: :8890
# ---- auth ------------------------------------------------------------- # ---- auth -------------------------------------------------------------
# Publishing is authorised by dumpd (/mtx-auth) with short-lived tokens minted for Discord-authenticated # Publishing is authorised by dumpd (/mtx-auth) with short-lived tokens minted for Discord-authenticated
# members of the streamers group. Reads are allowed; viewers are gated by Caddy + Authentik. No passwords. # members of the streamers group. Reads are allowed; viewers are gated by Caddy + Authentik. No passwords.
# MediaMTX hot-reloads this file: changing auth needs no restart. # NOTE: MediaMTX hot-reloads paths/recording settings but NOT the auth section — restart after auth changes.
authMethod: http authMethod: http
authHTTPAddress: http://{auth_gw}:8081/mtx-auth authHTTPAddress: http://{auth_gw}:8081/mtx-auth
authHTTPExclude: authHTTPExclude:
@@ -369,7 +375,7 @@ authHTTPExclude:
# ---- recording -------------------------------------------------------- # ---- recording --------------------------------------------------------
pathDefaults: pathDefaults:
record: yes record: yes
recordPath: /recordings/%path/.live/%Y-%m-%d_%H-%M-%S # hidden while recording; the sweep publishes the finished file one level up recordPath: /recordings/%path/.live/%Y-%m-%d_%H-%M-%S-%f # hidden while recording; microseconds = server arrival time (timecode fallback)
recordFormat: fmp4 recordFormat: fmp4
recordPartDuration: 1s recordPartDuration: 1s
recordSegmentDuration: 12h # one file per stream unless it runs >12h recordSegmentDuration: 12h # one file per stream unless it runs >12h
@@ -469,7 +475,8 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
run(dc + ["up", "-d", "--build"]) # recreates only services whose compose definition changed run(dc + ["up", "-d", "--build"]) # recreates only services whose compose definition changed
run(dc + ["restart", "copyparty"]) # copyparty re-reads its config on start run(dc + ["restart", "copyparty"]) # copyparty re-reads its config on start
run(dc + ["exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]) # zero-downtime run(dc + ["exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]) # zero-downtime
print(" (mediamtx hot-reloads mediamtx.yml itself — live streams are not interrupted)") print(" mediamtx: hot-reloads most of mediamtx.yml, but NOT auth changes. If you changed auth settings, run")
print(" docker compose restart mediamtx between streams (cuts a live recording).")
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""): def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
print(f"\n== domain : {domain or '(not set)'}") print(f"\n== domain : {domain or '(not set)'}")

26
dock/clock/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vanguard Clock</title>
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style>
</head>
<body>
<canvas id="c"></canvas>
<script src="vgclock.js"></script>
<script>
// OBS browser source for the overscan band: size it to the band (width = canvas width, height = band).
// It draws the server-synced strip + readable clock at the display rate. ?text=0 hides the readable clock.
(async () => {
const c = document.getElementById('c'), ctx = c.getContext('2d', { alpha: false });
const q = new URLSearchParams(location.search);
const showText = q.get('text') !== '0';
function fit() { c.width = Math.max(160, window.innerWidth); const { band } = VGClock.geometry(c.width); c.height = Math.max(band, window.innerHeight || band); }
fit(); window.addEventListener('resize', fit);
await VGClock.resync('/time');
setInterval(() => VGClock.resync('/time'), 60000);
(function frame() { VGClock.drawBand(ctx, c.width, 0, { text: showText }); requestAnimationFrame(frame); })();
})();
</script>
</body>
</html>

56
dock/clock/vgclock.js Normal file
View File

@@ -0,0 +1,56 @@
// Vanguard timecode strip — browser side. Shared by the OBS browser source (/dock/clock/) and the live portal.
// Format: see bin/vgstrip.py. cell = W/160; band = round_even(cell); 64 cells from x = cell:
// [1,0,1,0] · 48-bit server ms (MSB first) · CRC-8 (poly 0x07) · [0,1,0,1]; 1 = white, 0 = black.
(function (global) {
function crc8(bytes) { let c = 0; for (const b of bytes) { c ^= b; for (let i = 0; i < 8; i++) c = (c & 0x80) ? ((c << 1) ^ 0x07) & 0xff : (c << 1) & 0xff; } return c; }
function bitsFor(ms) {
const bytes = []; let v = BigInt(ms); for (let i = 5; i >= 0; i--) { bytes[i] = Number(v & 0xffn); v >>= 8n; }
const bits = [1, 0, 1, 0];
for (const b of bytes) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1);
const c = crc8(bytes); for (let i = 7; i >= 0; i--) bits.push((c >> i) & 1);
return bits.concat([0, 1, 0, 1]);
}
function geometry(w) { const cell = w / 160; let band = Math.round(cell); band += band % 2; return { cell, band }; }
// ---- server clock offset: offset = serverNow - localNow, from the lowest-RTT of several /time samples
const sync = { offset: 0, rtt: null, ok: false, samples: 0 };
async function sampleOnce(url) {
const t0 = performance.now(), wall0 = Date.now();
const r = await fetch(url, { cache: 'no-store', credentials: 'same-origin' }); const j = await r.json();
const t1 = performance.now(); const rtt = t1 - t0;
const localMid = wall0 + rtt / 2;
return { offset: j.now_ms - localMid, rtt };
}
async function resync(url, n = 7) {
let best = null;
for (let i = 0; i < n; i++) { try { const s = await sampleOnce(url); if (!best || s.rtt < best.rtt) best = s; } catch (e) {} }
if (best) { sync.offset = best.offset; sync.rtt = best.rtt; sync.ok = true; sync.samples++; }
return sync;
}
function serverNow() { return Date.now() + sync.offset; }
// ---- draw the band into a 2D context: strip + readable clock. (w = canvas width, y0 = band top)
function drawBand(ctx, w, y0, opts = {}) {
const { cell, band } = geometry(w);
const ms = Math.round(serverNow());
ctx.fillStyle = '#000'; ctx.fillRect(0, y0, w, band);
const bits = bitsFor(ms);
const pad = Math.max(1, Math.floor(cell * 0.12));
for (let i = 0; i < bits.length; i++) {
if (!bits[i]) continue;
const x0 = Math.round(cell * (1 + i)), x1 = Math.round(cell * (2 + i));
ctx.fillStyle = '#fff'; ctx.fillRect(x0 + pad, y0 + pad, x1 - x0 - 2 * pad, band - 2 * pad);
}
if (opts.text !== false) {
const d = new Date(ms);
const tz = opts.tz || 'America/New_York';
const hms = d.toLocaleTimeString('en-US', { hour12: false, timeZone: tz });
const txt = `${hms}.${String(ms % 1000).padStart(3, '0')} ${opts.label || 'SRV'}${sync.ok ? '' : ' (unsynced)'}`;
ctx.fillStyle = sync.ok ? '#9f9' : '#f66'; ctx.font = `${Math.max(8, band - 2 * pad)}px ui-monospace, monospace`; ctx.textBaseline = 'middle';
ctx.fillText(txt, Math.round(cell * (CELLS + 2.5)), y0 + band / 2);
}
return ms;
}
const CELLS = 64;
global.VGClock = { crc8, bitsFor, geometry, resync, serverNow, drawBand, sync, CELLS };
})(window);

View File

@@ -58,6 +58,17 @@
<button id="save">Save settings</button> <button id="save">Save settings</button>
</details> </details>
<details id="tcband">
<summary>Timecode band (frame-accurate multicam sync)</summary>
<div class="who" style="margin-top:6px">Adds a thin "overscan" band below your canvas with the server clock encoded in it.
The server decodes it after each stream so editors get a frame-accurate timecode track; the live portal and
editors crop the band, Jellyfin shows it as a thin strip at the bottom. OBS must not be streaming or recording.</div>
<div class="kv" id="tc_state">canvas: — · band: —</div>
<button class="small" id="tc_add">Add band + clock source</button>
<button class="small" id="tc_scene">Add clock to current scene</button>
<button class="small" id="tc_remove">Remove band</button>
</details>
<details id="manual"> <details id="manual">
<summary>Manual OBS setup (endpoints + token)</summary> <summary>Manual OBS setup (endpoints + token)</summary>
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div> <div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
@@ -166,6 +177,78 @@ function obs(c){
}); });
} }
// ---- timecode band (obs-websocket) -------------------------------------------------------------------
const CLOCK_INPUT = "Vanguard Clock";
const bandFor = w => { let b = Math.round(w / 160); return b + (b % 2); };
async function tcState() {
try {
const o = await obs(cfg());
const v = await o.call("GetVideoSettings");
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
const has = inputs.inputs.some(i => i.inputName === CLOCK_INPUT);
const band = bandFor(v.baseWidth);
const hasBand = (v.baseHeight % 2 === 0) && [720, 1080, 1440, 2160].some(h => v.baseHeight === h + bandFor(v.baseWidth));
o.close();
$("tc_state").textContent = `canvas: ${v.baseWidth}×${v.baseHeight} (output ${v.outputWidth}×${v.outputHeight}) · band: ${hasBand ? band + " px present" : "not set"} · clock source: ${has ? "present" : "absent"}`;
return { v, has, band, hasBand };
} catch (e) { $("tc_state").textContent = "OBS not reachable: " + e.message; return null; }
}
async function notStreaming(o) {
const s = await o.call("GetStreamStatus"), r = await o.call("GetRecordStatus");
if (s.outputActive || r.outputActive) throw new Error("stop streaming/recording first — the canvas size cannot change while an output is active");
}
async function ensureClockInput(o, width, band, scene) {
const url = `${location.origin}/dock/clock/`;
const inputs = await o.call("GetInputList", { inputKind: "browser_source" });
const settings = { url, width, height: band, fps_custom: true, fps: 60, shutdown: false, restart_when_active: false, reroute_audio: false };
if (!inputs.inputs.some(i => i.inputName === CLOCK_INPUT)) {
await o.call("CreateInput", { sceneName: scene, inputName: CLOCK_INPUT, inputKind: "browser_source", inputSettings: settings, sceneItemEnabled: true });
} else {
await o.call("SetInputSettings", { inputName: CLOCK_INPUT, inputSettings: settings, overlay: true });
const items = await o.call("GetSceneItemList", { sceneName: scene });
if (!items.sceneItems.some(i => i.sourceName === CLOCK_INPUT)) await o.call("CreateSceneItem", { sceneName: scene, sourceName: CLOCK_INPUT, sceneItemEnabled: true });
}
const items = await o.call("GetSceneItemList", { sceneName: scene });
const it = items.sceneItems.find(i => i.sourceName === CLOCK_INPUT);
if (it) {
const v = await o.call("GetVideoSettings");
await o.call("SetSceneItemTransform", { sceneName: scene, sceneItemId: it.sceneItemId,
sceneItemTransform: { positionX: 0, positionY: v.baseHeight - band, scaleX: 1, scaleY: 1, boundsType: "OBS_BOUNDS_NONE", cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0 } });
await o.call("SetSceneItemLocked", { sceneName: scene, sceneItemId: it.sceneItemId, sceneItemLocked: true });
}
}
$("tc_add").onclick = async () => {
try {
const o = await obs(cfg()); await notStreaming(o);
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
const scale = v.outputHeight / v.baseHeight;
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH + band, outputWidth: v.outputWidth, outputHeight: Math.round((gameH + band) * scale / 2) * 2 });
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
await ensureClockInput(o, v.baseWidth, band, scene); o.close();
status(`band added: canvas ${v.baseWidth}×${gameH + band}, clock source in "${scene}"`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tc_scene").onclick = async () => {
try {
const o = await obs(cfg()); const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const scene = (await o.call("GetCurrentProgramScene")).currentProgramSceneName;
await ensureClockInput(o, v.baseWidth, band, scene); o.close(); status(`clock source placed in "${scene}"`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tc_remove").onclick = async () => {
try {
const o = await obs(cfg()); await notStreaming(o);
const v = await o.call("GetVideoSettings"); const band = bandFor(v.baseWidth);
const gameH = [720, 1080, 1440, 2160].find(h => v.baseHeight === h + band) ? v.baseHeight - band : v.baseHeight;
try { await o.call("RemoveInput", { inputName: CLOCK_INPUT }); } catch {}
const scale = v.outputHeight / v.baseHeight;
await o.call("SetVideoSettings", { baseWidth: v.baseWidth, baseHeight: gameH, outputWidth: v.outputWidth, outputHeight: Math.round(gameH * scale / 2) * 2 });
o.close(); status(`band removed: canvas ${v.baseWidth}×${gameH}`, "ok"); tcState();
} catch (e) { status(e.message, "bad"); }
};
$("tcband").addEventListener("toggle", () => { if ($("tcband").open) tcState(); });
// ---- actions --------------------------------------------------------------- // ---- actions ---------------------------------------------------------------
$("golive").onclick = async () => { $("golive").onclick = async () => {
const c = cfg(), title = buildTitle($("title").value); const c = cfg(), title = buildTitle($("title").value);
@@ -216,7 +299,13 @@ $("signout").onclick = async e => {
const auth = "https://" + location.hostname.replace(/^[^.]+\./, "auth.") + "/if/flow/default-invalidation-flow/?next=" + encodeURIComponent(location.origin + "/dock/"); const auth = "https://" + location.hostname.replace(/^[^.]+\./, "auth.") + "/if/flow/default-invalidation-flow/?next=" + encodeURIComponent(location.origin + "/dock/");
setTimeout(() => location.replace(auth), 400); setTimeout(() => location.replace(auth), 400);
}; };
whoami(); renderPreview(); // On load: who am I, and mint a token right away so the preview and the manual section show real values.
(async () => {
await whoami();
try { await mint(); status("ready"); }
catch (e) { status(e.message, "bad"); $("m_tok").textContent = "— " + e.message; }
})();
renderPreview();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -48,7 +48,7 @@ for d in /srv/media/clips/*/; do
done done
echo "== host scripts + units" echo "== host scripts + units"
install -m 0755 bin/normalize-sweep.sh bin/trash-purge.sh bin/dumpd.py /usr/local/bin/ install -m 0755 bin/normalize-sweep.sh bin/trash-purge.sh bin/dumpd.py bin/vgstrip.py /usr/local/bin/
install -d -m 0750 /etc/vanguard install -d -m 0750 /etc/vanguard
install -m 0640 dump-users.txt /etc/vanguard/dump-users.txt install -m 0640 dump-users.txt /etc/vanguard/dump-users.txt
install -m 0644 systemd/*.service systemd/*.timer /etc/systemd/system/ install -m 0644 systemd/*.service systemd/*.timer /etc/systemd/system/

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, MAX_KBPS } from './broadcast.js' import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, withTimecodeBand, MAX_KBPS } from './broadcast.js'
// Broadcaster view: replaces the viewer grid while you're live. // Broadcaster view: replaces the viewer grid while you're live.
export default function GoLive({ onExit }) { export default function GoLive({ onExit }) {
@@ -14,6 +14,8 @@ export default function GoLive({ onExit }) {
const [started, setStarted] = useState(null) const [started, setStarted] = useState(null)
const [elapsed, setElapsed] = useState(0) const [elapsed, setElapsed] = useState(0)
const [hasAudio, setHasAudio] = useState(true) const [hasAudio, setHasAudio] = useState(true)
const [tcBand, setTcBand] = useState(() => { try { return localStorage.getItem('vg.tcband') !== '0' } catch { return true } })
const bandRef = useRef(null)
useEffect(() => { fetchStreamKey().then(setKey).catch(e => { setErr(e.message); setPhase('error') }) }, []) useEffect(() => { fetchStreamKey().then(setKey).catch(e => { setErr(e.message); setPhase('error') }) }, [])
useEffect(() => { if (!started) return; const t = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => clearInterval(t) }, [started]) useEffect(() => { if (!started) return; const t = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => clearInterval(t) }, [started])
@@ -40,16 +42,18 @@ export default function GoLive({ onExit }) {
if (!key) return setErr('no stream key') if (!key) return setErr('no stream key')
try { try {
setPhase('capturing') setPhase('capturing')
const stream = await captureWindow() const captured = await captureWindow()
setHasAudio(stream.getAudioTracks().length > 0) setHasAudio(captured.getAudioTracks().length > 0)
captured.getVideoTracks()[0].onended = () => stop() // user clicked Chrome's "Stop sharing"
let stream = captured
if (tcBand) { bandRef.current = withTimecodeBand(captured); stream = bandRef.current.stream }
if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.muted = true; videoRef.current.play().catch(() => {}) } if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.muted = true; videoRef.current.play().catch(() => {}) }
stream.getVideoTracks()[0].onended = () => stop() // user clicked Chrome's "Stop sharing"
const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: setConn }) const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: setConn })
sessionRef.current = session sessionRef.current = session
setStarted(Date.now()); setPhase('live') setStarted(Date.now()); setPhase('live')
} catch (e) { setErr(e.message); setPhase(e.message === 'capture cancelled' ? 'setup' : 'error') } } catch (e) { setErr(e.message); setPhase(e.message === 'capture cancelled' ? 'setup' : 'error') }
} }
const stop = async () => { await sessionRef.current?.stop(); sessionRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setPhase('setup'); setStarted(null); setStats(null); setConn('') } const stop = async () => { await sessionRef.current?.stop(); sessionRef.current = null; bandRef.current?.stop(); bandRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setPhase('setup'); setStarted(null); setStats(null); setConn('') }
const mmss = s => `${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` const mmss = s => `${String(Math.floor(s / 3600)).padStart(2, '0')}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
return ( return (
@@ -74,6 +78,11 @@ export default function GoLive({ onExit }) {
encoder. On Linux and Mac Chrome has no window audio and (on NVIDIA) no hardware encoder, so the stream is encoder. On Linux and Mac Chrome has no window audio and (on NVIDIA) no hardware encoder, so the stream is
silent and visibly soft — use OBS with the dock there. Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264. silent and visibly soft — use OBS with the dock there. Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264.
</div> </div>
<label className="hint" style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="checkbox" checked={tcBand} onChange={e => { setTcBand(e.target.checked); try { localStorage.setItem('vg.tcband', e.target.checked ? '1' : '0') } catch {} }} />
<span>Timecode band: add a thin strip below the picture carrying the server clock, so editors get a
frame-accurate timecode track. Viewers here never see it; Jellyfin shows a thin line at the bottom.</span>
</label>
<div className="warn-box"> <div className="warn-box">
<b>⚠ This stream is recorded.</b> Everything the window shows and every sound it makes is saved to the <b>⚠ This stream is recorded.</b> Everything the window shows and every sound it makes is saved to the
server, visible to editors and members in Jellyfin, and kept until it is dumped or archived. Close server, visible to editors and members in Jellyfin, and kept until it is dumped or archived. Close

View File

@@ -9,6 +9,7 @@ export default function Player({ params, api }) {
const videoRef = useRef(null) const videoRef = useRef(null)
const [status, setStatus] = useState('connecting…') const [status, setStatus] = useState('connecting…')
const [viewers, setViewers] = useState(params.viewers ?? 0) const [viewers, setViewers] = useState(params.viewers ?? 0)
const [aspect, setAspect] = useState(null) // game aspect once we know the video size (crops the timecode band)
const state = audio.use() const state = audio.use()
const muted = effectiveMuted(state, id) const muted = effectiveMuted(state, id)
const volume = state.streams[id]?.volume ?? 0.8 const volume = state.streams[id]?.volume ?? 0.8
@@ -72,7 +73,13 @@ export default function Player({ params, api }) {
const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊' const icon = muted ? '🔇' : volume === 0 ? '🔈' : volume < 0.5 ? '🔉' : '🔊'
return ( return (
<div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}> <div className={'player' + (focused ? ' focused' : '')} onMouseDown={() => api.setActive?.()}>
<video ref={videoRef} autoPlay playsInline muted /> <div className="vwrap" style={aspect ? { aspectRatio: aspect } : undefined}>
<video ref={videoRef} autoPlay playsInline muted className={aspect ? 'cropband' : ''}
onLoadedMetadata={e => { const v = e.currentTarget; const w = v.videoWidth, h = v.videoHeight; if (!w || !h) return
let band = Math.round(w / 160); band += band % 2; const gameH = h - band
// only treat it as a band if the remaining height is a standard game height
setAspect([720, 1080, 1440, 2160].includes(gameH) ? `${w} / ${gameH}` : null) }} />
</div>
{status && <div className="status">{status}</div>} {status && <div className="status">{status}</div>}
<div className="bar"> <div className="bar">
<span className="live">LIVE</span> <span className="live">LIVE</span>

View File

@@ -1,6 +1,8 @@
// Publish a captured window to MediaMTX over WHIP (WebRTC), through Caddy at /webrtc/<path>/whip. // Publish a captured window to MediaMTX over WHIP (WebRTC), through Caddy at /webrtc/<path>/whip.
// Credentials go as query params so the Authentik forward-auth in front doesn't see an Authorization header. // Credentials go as query params so the Authentik forward-auth in front doesn't see an Authorization header.
import VGClock from './vgclock.js'
export const MAX_KBPS = 12000 export const MAX_KBPS = 12000
export function defaultTitle(now = new Date()) { export function defaultTitle(now = new Date()) {
@@ -60,6 +62,29 @@ function mungeBitrates(sdp) {
: line) : line)
} }
// Overscan band: draw the captured video onto a canvas that is `band` rows taller and paint the
// server-synced timecode strip into the extra rows every frame. Returns a new MediaStream.
export function withTimecodeBand(stream) {
const vt = stream.getVideoTracks()[0]; if (!vt) return { stream, stop: () => {} }
const st = vt.getSettings(); const W = st.width || 1920, H = st.height || 1080, fps = Math.min(60, st.frameRate || 60)
const { band } = VGClock.geometry(W)
const video = document.createElement('video'); video.muted = true; video.playsInline = true; video.srcObject = new MediaStream([vt]); video.play().catch(() => {})
const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H + band
const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true })
let running = true
const draw = () => {
if (!running) return
if (video.readyState >= 2) ctx.drawImage(video, 0, 0, W, H)
VGClock.drawBand(ctx, W, H, { text: true })
if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(draw); else requestAnimationFrame(draw)
}
draw()
VGClock.resync('/time'); const t = setInterval(() => VGClock.resync('/time'), 60000)
const out = canvas.captureStream(fps)
for (const a of stream.getAudioTracks()) out.addTrack(a)
return { stream: out, stop: () => { running = false; clearInterval(t); video.srcObject = null } }
}
export async function publish({ stream, path, user, pass, onState }) { export async function publish({ stream, path, user, pass, onState }) {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }) const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
for (const track of stream.getTracks()) { for (const track of stream.getTracks()) {

View File

@@ -16,7 +16,10 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
--dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2); --dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2);
--dv-drag-over-border-color:var(--acc); --dv-tabs-container-scrollbar-color:#444; --dv-icon-hover-background-color:#333; } --dv-drag-over-border-color:var(--acc); --dv-tabs-container-scrollbar-color:#444; --dv-icon-hover-background-color:#333; }
.player { position:relative; width:100%; height:100%; background:#000; display:flex; flex-direction:column; } .player { position:relative; width:100%; height:100%; background:#000; display:flex; flex-direction:column; }
.player video { flex:1; min-height:0; width:100%; height:100%; object-fit:contain; background:#000; } .player .vwrap { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; background:#000; }
.player .vwrap[style*="aspect-ratio"] { max-height:100%; max-width:100%; margin:auto; }
.player video { width:100%; height:100%; object-fit:contain; background:#000; }
.player video.cropband { object-fit:cover; object-position:50% 0; } /* top-aligned cover inside a game-aspect box = band cropped */
.player .bar { display:flex; align-items:center; gap:10px; padding:6px 10px; background:#181818; border-top:1px solid #2a2a2a; font-size:13px; } .player .bar { display:flex; align-items:center; gap:10px; padding:6px 10px; background:#181818; border-top:1px solid #2a2a2a; font-size:13px; }
.player .live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; letter-spacing:.5px; } .player .live { background:var(--live); color:#fff; font-weight:700; font-size:11px; padding:2px 6px; border-radius:4px; letter-spacing:.5px; }
.player .who { font-weight:600; } .player .who { font-weight:600; }

57
live/src/vgclock.js Normal file
View File

@@ -0,0 +1,57 @@
// Vanguard timecode strip — browser side. Shared by the OBS browser source (/dock/clock/) and the live portal.
// Format: see bin/vgstrip.py. cell = W/160; band = round_even(cell); 64 cells from x = cell:
// [1,0,1,0] · 48-bit server ms (MSB first) · CRC-8 (poly 0x07) · [0,1,0,1]; 1 = white, 0 = black.
(function (global) {
function crc8(bytes) { let c = 0; for (const b of bytes) { c ^= b; for (let i = 0; i < 8; i++) c = (c & 0x80) ? ((c << 1) ^ 0x07) & 0xff : (c << 1) & 0xff; } return c; }
function bitsFor(ms) {
const bytes = []; let v = BigInt(ms); for (let i = 5; i >= 0; i--) { bytes[i] = Number(v & 0xffn); v >>= 8n; }
const bits = [1, 0, 1, 0];
for (const b of bytes) for (let i = 7; i >= 0; i--) bits.push((b >> i) & 1);
const c = crc8(bytes); for (let i = 7; i >= 0; i--) bits.push((c >> i) & 1);
return bits.concat([0, 1, 0, 1]);
}
function geometry(w) { const cell = w / 160; let band = Math.round(cell); band += band % 2; return { cell, band }; }
// ---- server clock offset: offset = serverNow - localNow, from the lowest-RTT of several /time samples
const sync = { offset: 0, rtt: null, ok: false, samples: 0 };
async function sampleOnce(url) {
const t0 = performance.now(), wall0 = Date.now();
const r = await fetch(url, { cache: 'no-store', credentials: 'same-origin' }); const j = await r.json();
const t1 = performance.now(); const rtt = t1 - t0;
const localMid = wall0 + rtt / 2;
return { offset: j.now_ms - localMid, rtt };
}
async function resync(url, n = 7) {
let best = null;
for (let i = 0; i < n; i++) { try { const s = await sampleOnce(url); if (!best || s.rtt < best.rtt) best = s; } catch (e) {} }
if (best) { sync.offset = best.offset; sync.rtt = best.rtt; sync.ok = true; sync.samples++; }
return sync;
}
function serverNow() { return Date.now() + sync.offset; }
// ---- draw the band into a 2D context: strip + readable clock. (w = canvas width, y0 = band top)
function drawBand(ctx, w, y0, opts = {}) {
const { cell, band } = geometry(w);
const ms = Math.round(serverNow());
ctx.fillStyle = '#000'; ctx.fillRect(0, y0, w, band);
const bits = bitsFor(ms);
const pad = Math.max(1, Math.floor(cell * 0.12));
for (let i = 0; i < bits.length; i++) {
if (!bits[i]) continue;
const x0 = Math.round(cell * (1 + i)), x1 = Math.round(cell * (2 + i));
ctx.fillStyle = '#fff'; ctx.fillRect(x0 + pad, y0 + pad, x1 - x0 - 2 * pad, band - 2 * pad);
}
if (opts.text !== false) {
const d = new Date(ms);
const tz = opts.tz || 'America/New_York';
const hms = d.toLocaleTimeString('en-US', { hour12: false, timeZone: tz });
const txt = `${hms}.${String(ms % 1000).padStart(3, '0')} ${opts.label || 'SRV'}${sync.ok ? '' : ' (unsynced)'}`;
ctx.fillStyle = sync.ok ? '#9f9' : '#f66'; ctx.font = `${Math.max(8, band - 2 * pad)}px ui-monospace, monospace`; ctx.textBaseline = 'middle';
ctx.fillText(txt, Math.round(cell * (CELLS + 2.5)), y0 + band / 2);
}
return ms;
}
const CELLS = 64;
global.VGClock = { crc8, bitsFor, geometry, resync, serverNow, drawBand, sync, CELLS };
})(typeof window !== "undefined" ? window : globalThis);
export default (typeof window !== "undefined" ? window : globalThis).VGClock;

View File

@@ -51,7 +51,7 @@ authInternalUsers:
# ---- recording -------------------------------------------------------- # ---- recording --------------------------------------------------------
pathDefaults: pathDefaults:
record: yes record: yes
recordPath: /recordings/%path/.live/%Y-%m-%d_%H-%M-%S # hidden while recording; the sweep publishes the finished file one level up recordPath: /recordings/%path/.live/%Y-%m-%d_%H-%M-%S-%f # hidden while recording; microseconds = server arrival time (timecode fallback)
recordFormat: fmp4 recordFormat: fmp4
recordPartDuration: 1s recordPartDuration: 1s
recordSegmentDuration: 12h # one file per stream unless it runs >12h recordSegmentDuration: 12h # one file per stream unless it runs >12h