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:
2026-10-31 16:07:56 -05:00
parent 89841c3d2a
commit 3fcb8fbe99
18 changed files with 515 additions and 25 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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()) {

View File

@@ -16,7 +16,10 @@ html, body, #root { height:100%; margin:0; background:var(--bg); color:var(--fg)
--dv-separator-border:#2a2a2a; --dv-group-view-background-color:var(--panel); --dv-drag-over-background-color:rgba(61,139,253,.2);
--dv-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
View File

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