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:
@@ -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;
|
||||
Reference in New Issue
Block a user