From 3fcb8fbe99c1a959795bd6e585d71dbf1952609c Mon Sep 17 00:00:00 2001 From: Brandyn / Techy Date: Sat, 31 Oct 2026 16:07:56 -0500 Subject: [PATCH] =?UTF-8?q?Phase=2012:=20frame-accurate=20timecode=20via?= =?UTF-8?q?=20an=20overscan=20band=20=E2=80=94=20vgstrip=20encoder/decoder?= =?UTF-8?q?,=20sweep=20writes=20tmcd=20+=20creation=5Ftime=20+=20sidecar,?= =?UTF-8?q?=20dumpd=20/time,=20Caddy=20/time=20routes,=20MediaMTX=20micros?= =?UTF-8?q?econd=20names,=20/dock/clock/=20page,=20dock=20band=20setup=20v?= =?UTF-8?q?ia=20obs-websocket,=20Go=20Live=20band=20composite=20+=20toggle?= =?UTF-8?q?,=20portal=20crops=20the=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CommunityDiscordMedia.md | 12 +-- README.md | 25 +++++- STATUS.md | 8 +- bin/dumpd.py | 3 + bin/normalize-sweep.sh | 30 +++++++- bin/vgstrip.py | 160 +++++++++++++++++++++++++++++++++++++++ check.sh | 8 +- configure.py | 10 ++- dock/clock/index.html | 26 +++++++ dock/clock/vgclock.js | 56 ++++++++++++++ dock/index.html | 83 ++++++++++++++++++++ install.sh | 2 +- live/src/GoLive.jsx | 19 +++-- live/src/Player.jsx | 9 ++- live/src/broadcast.js | 25 ++++++ live/src/styles.css | 5 +- live/src/vgclock.js | 57 ++++++++++++++ mediamtx.example.yml | 2 +- 18 files changed, 515 insertions(+), 25 deletions(-) create mode 100755 bin/vgstrip.py create mode 100644 dock/clock/index.html create mode 100644 dock/clock/vgclock.js create mode 100644 live/src/vgclock.js diff --git a/CommunityDiscordMedia.md b/CommunityDiscordMedia.md index 0749e48..70f9600 100644 --- a/CommunityDiscordMedia.md +++ b/CommunityDiscordMedia.md @@ -287,7 +287,7 @@ Discord roles); the publish path must start with the user's own name. - [ ] 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 -### Phase 12 — frame-accurate timecode for multicam via an overscan band (planned) +### 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. @@ -311,11 +311,11 @@ permanent reference is needed for long sessions, not just a start burst. - **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`. -- [ ] `/clock/` page: offset measurement, strip encoder, readable clock, band layout -- [ ] dock: overscan band setup + browser source via obs-websocket; opt-out -- [ ] portal Go Live: band composite (toggle); portal viewer: CSS crop of the band -- [ ] sweep: `bin/timecode-decode.py` (ffmpeg frames → decode → offset + drift), tmcd + creation_time, `.tc.json` -- [ ] MediaMTX `%f`; plugin timestamp update; README editor workflow (multicam: timecode → audio cross-check; +- [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, `.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) diff --git a/README.md b/README.md index ff9b960..ae24129 100644 --- a/README.md +++ b/README.md @@ -293,9 +293,32 @@ the expiry, and that the path starts with that user's name. MediaMTX hot-reloads 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 +`__<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 /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 the streamer's folder (several files inside one title folder would be ONE Jellyfin episode with "versions") @@ -317,7 +340,7 @@ upscaled; aspect kept). | input | action | |-------|--------| | 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) | | 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 | diff --git a/STATUS.md b/STATUS.md index 7b05b0f..635bb6f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 | 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) | -| 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 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 | @@ -25,8 +25,10 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea | Who | Activity | Where | Login | |---|---|---|---| +| 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 | — | +| 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 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** | @@ -45,7 +47,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea |---|---|---| | MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens via `/mtx-auth`) | live, token auth verified | | copyparty | browse/scrub/download/finals upload | behind Authentik, group permissions (`editors`, `media-admins`), Discord-only, no WebDAV | live | -| dumpd + dock | dump, stream tokens, MediaMTX auth hook; OBS dock | identity from Authentik headers | live | +| dumpd + dock | dump, stream tokens, MediaMTX auth hook, `/time`; OBS dock with timecode-band setup | identity from Authentik headers | live; band/timecode **pending deploy** | +| 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 | | nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live | @@ -62,6 +65,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea ## Unfinished, in rough priority +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. 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. 2. Confirm plugin 0.1.3 under Jellyfin → Plugins and the Gitea runner registered. 3. Portal viewer during a real stream: corner chip `webrtc`; Windows Go Live with WarDogs audio (first Windows streamer). diff --git a/bin/dumpd.py b/bin/dumpd.py index 0dfd8db..da946b0 100755 --- a/bin/dumpd.py +++ b/bin/dumpd.py @@ -161,6 +161,9 @@ class H(BaseHTTPRequestHandler): def do_GET(self): if self.server.server_port == AUTH_LISTEN[1]: 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"): # Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers. who, _ = identity(self.headers) diff --git a/bin/normalize-sweep.sh b/bin/normalize-sweep.sh index 4319f40..9a9fdf9 100755 --- a/bin/normalize-sweep.sh +++ b/bin/normalize-sweep.sh @@ -104,9 +104,32 @@ while IFS= read -r -d '' src; do fi tmp="$out.part" 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 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 # 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" @@ -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 \ -vf "$vf" -fps_mode cfr "${fpsargs[@]}" \ -c:a aac -b:a 160k \ - -movflags +faststart -f mp4 "$tmp" + "${tcargs[@]}" -movflags +faststart -f mp4 "$tmp" fi rc=$? d1=$(dur "$src"); d2=$(dur "$tmp") if (( rc == 0 )) && [[ -n $d1 && -n $d2 ]] && (( d2 >= d1 - 2 && d2 <= d1 + 2 )); then if (( live )); then 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 log "done $out"; continue elif (( synced )); then diff --git a/bin/vgstrip.py b/bin/vgstrip.py new file mode 100755 index 0000000..2bb4287 --- /dev/null +++ b/bin/vgstrip.py @@ -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)) diff --git a/check.sh b/check.sh index ace71a8..351561b 100755 --- a/check.sh +++ b/check.sh @@ -9,14 +9,16 @@ for f in install.sh addCommunityMediaServer.sh installJellyfinPlugin.sh register [[ -f $f ]] || continue bash -n "$f" && say "$f" ok || { say "$f" "BASH SYNTAX"; fail=1; } 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; } done 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; } node -e " -const fs=require('fs');const html=fs.readFileSync('dock/index.html','utf8'); -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; } +const fs=require('fs'); +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 T=$(mktemp -d); cp configure.py "$T/"; ( cd "$T" && VG_DRY=1 python3 -c " import configure as c, yaml diff --git a/configure.py b/configure.py index c850452..daad6ac 100755 --- a/configure.py +++ b/configure.py @@ -167,7 +167,10 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l {live_host} {{ encode zstd gzip {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/* {{ reverse_proxy 127.0.0.1:9997 @@ -256,6 +259,9 @@ name: {{http.request.header.X-Authentik-Name}}" handle /me/* {{ 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 {sync_gate} redir /sync /sync/ 308 @@ -369,7 +375,7 @@ authHTTPExclude: # ---- recording -------------------------------------------------------- pathDefaults: 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 recordPartDuration: 1s recordSegmentDuration: 12h # one file per stream unless it runs >12h diff --git a/dock/clock/index.html b/dock/clock/index.html new file mode 100644 index 0000000..6d97771 --- /dev/null +++ b/dock/clock/index.html @@ -0,0 +1,26 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>Vanguard Clock + + + + + + + + diff --git a/dock/clock/vgclock.js b/dock/clock/vgclock.js new file mode 100644 index 0000000..a2816da --- /dev/null +++ b/dock/clock/vgclock.js @@ -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); diff --git a/dock/index.html b/dock/index.html index 9b21914..f844905 100644 --- a/dock/index.html +++ b/dock/index.html @@ -58,6 +58,17 @@ +
+ Timecode band (frame-accurate multicam sync) +
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.
+
canvas: — · band: —
+ + + +
+
Manual OBS setup (endpoints + token)
RTMP server —
@@ -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 --------------------------------------------------------------- $("golive").onclick = async () => { const c = cfg(), title = buildTitle($("title").value); diff --git a/install.sh b/install.sh index d5c8351..8c317bb 100755 --- a/install.sh +++ b/install.sh @@ -48,7 +48,7 @@ for d in /srv/media/clips/*/; do done 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 -m 0640 dump-users.txt /etc/vanguard/dump-users.txt install -m 0644 systemd/*.service systemd/*.timer /etc/systemd/system/ diff --git a/live/src/GoLive.jsx b/live/src/GoLive.jsx index 580077f..9cf6d3e 100644 --- a/live/src/GoLive.jsx +++ b/live/src/GoLive.jsx @@ -1,5 +1,5 @@ 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. export default function GoLive({ onExit }) { @@ -14,6 +14,8 @@ export default function GoLive({ onExit }) { const [started, setStarted] = useState(null) const [elapsed, setElapsed] = useState(0) 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(() => { 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') try { setPhase('capturing') - const stream = await captureWindow() - setHasAudio(stream.getAudioTracks().length > 0) + const captured = await captureWindow() + 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(() => {}) } - 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 }) sessionRef.current = session setStarted(Date.now()); setPhase('live') } 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')}` 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 silent and visibly soft — use OBS with the dock there. Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264. +
⚠ This stream is recorded. 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 diff --git a/live/src/Player.jsx b/live/src/Player.jsx index 2e9e6ad..dfe3288 100644 --- a/live/src/Player.jsx +++ b/live/src/Player.jsx @@ -9,6 +9,7 @@ export default function Player({ params, api }) { const videoRef = useRef(null) const [status, setStatus] = useState('connecting…') 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 muted = effectiveMuted(state, id) 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 ? '🔉' : '🔊' return (
api.setActive?.()}> -