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>
This commit is contained in:
@@ -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, `<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)
|
||||
|
||||
|
||||
25
README.md
25
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
|
||||
`<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
|
||||
|
||||
/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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
160
bin/vgstrip.py
Executable file
160
bin/vgstrip.py
Executable 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))
|
||||
8
check.sh
8
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
|
||||
|
||||
10
configure.py
10
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
|
||||
|
||||
26
dock/clock/index.html
Normal file
26
dock/clock/index.html
Normal 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
56
dock/clock/vgclock.js
Normal 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);
|
||||
@@ -58,6 +58,17 @@
|
||||
<button id="save">Save settings</button>
|
||||
</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">
|
||||
<summary>Manual OBS setup (endpoints + token)</summary>
|
||||
<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 ---------------------------------------------------------------
|
||||
$("golive").onclick = async () => {
|
||||
const c = cfg(), title = buildTitle($("title").value);
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
</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">
|
||||
<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
|
||||
|
||||
@@ -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 (
|
||||
<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>}
|
||||
<div className="bar">
|
||||
<span className="live">LIVE</span>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 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.
|
||||
|
||||
import VGClock from './vgclock.js'
|
||||
|
||||
export const MAX_KBPS = 12000
|
||||
|
||||
export function defaultTitle(now = new Date()) {
|
||||
@@ -60,6 +62,29 @@ function mungeBitrates(sdp) {
|
||||
: 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 }) {
|
||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
||||
for (const track of stream.getTracks()) {
|
||||
|
||||
@@ -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-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 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 .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; }
|
||||
|
||||
57
live/src/vgclock.js
Normal file
57
live/src/vgclock.js
Normal 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;
|
||||
@@ -51,7 +51,7 @@ authInternalUsers:
|
||||
# ---- 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
|
||||
|
||||
Reference in New Issue
Block a user