bunch of shit

This commit is contained in:
2026-11-06 19:05:47 -06:00
parent ca01516d42
commit 630b253278
29 changed files with 2793 additions and 303 deletions

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, withTimecodeBand, MAX_KBPS } from './broadcast.js'
import { captureWindow, publish, fetchStreamKey, defaultTitle, cleanTitle, withTimecodeBand, MAX_KBPS, windowLoss, shouldSwitchToTcp } from './broadcast.js'
// Broadcaster view: replaces the viewer grid while you're live.
export default function GoLive({ onExit }) {
@@ -16,6 +16,81 @@ export default function GoLive({ onExit }) {
const [hasAudio, setHasAudio] = useState(true)
const [tcBand, setTcBand] = useState(() => { try { return localStorage.getItem('vg.tcband') !== '0' } catch { return true } })
const bandRef = useRef(null)
const streamRef = useRef(null) // the tracks being published (survive a transport switch)
const samplesRef = useRef([]) // recent {t, lost, sent} for the loss window
const switchingRef = useRef(false)
const [transport, setTransport] = useState('udp')
const [switched, setSwitched] = useState('') // "switched to TCP after 0.6% loss" once it happens
// Remembered per browser. Starlink drops packets in bursts on every satellite handoff (its scheduler works in
// ~15 s cycles), so a dish user hits the auto-switch on every single stream; once it has fired, the next stream
// starts on TCP and skips the 20 s of corrupted recording and the extra file. Untick in setup to go back.
const [preferTcp, setPreferTcp] = useState(() => { try { return localStorage.getItem('vg.golive.tcp') === '1' } catch { return false } })
const [ended, setEnded] = useState('') // why the server ended the stream, shown back on the setup card
const [surface, setSurface] = useState(() => { try { return localStorage.getItem('vg.golive.surface') === 'screen' ? 'screen' : 'window' } catch { return 'window' } })
const pickSurface = v => { setSurface(v); try { localStorage.setItem('vg.golive.surface', v) } catch {} }
const missingRef = useRef(0) // consecutive health polls that did not list our path
const disconnectRef = useRef(null)
// A kick is invisible to WebRTC for a long time: the browser sits in "disconnected" until ICE consent times
// out (~30 s) and only then goes "failed". So: treat failed/closed as the end immediately, give
// "disconnected" 8 s to recover, and ask the server every 5 s whether our path is still live — the kick
// path answers that in seconds and tells us whether we are blocked.
const endedByServer = async (why) => {
if (!sessionRef.current) return
const s = sessionRef.current; sessionRef.current = null
try { await s.stop() } catch {}
streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null
bandRef.current?.stop(); bandRef.current = null
if (videoRef.current) videoRef.current.srcObject = null
setPhase('setup'); setStarted(null); setStats(null); setConn(''); setTransport('udp'); setSwitched('')
setEnded(why)
}
const onConn = (state) => {
setConn(state)
if (switchingRef.current) return
if (state === 'failed' || state === 'closed') endedByServer('The server ended the stream.')
if (state === 'disconnected' && !disconnectRef.current) {
disconnectRef.current = setTimeout(() => { disconnectRef.current = null; if (sessionRef.current?.pc?.connectionState !== 'connected') endedByServer('Connection to the server was lost.') }, 8000)
}
if (state === 'connected' && disconnectRef.current) { clearTimeout(disconnectRef.current); disconnectRef.current = null }
}
useEffect(() => {
if (phase !== 'live') return
missingRef.current = 0
const t = setInterval(async () => {
const s = sessionRef.current; if (!s || switchingRef.current) return
try {
const r = await fetch('/me/stream-health', { credentials: 'same-origin' }); if (!r.ok) return
const d = await r.json()
const mine = (d.streams || []).some(x => x.path.toLowerCase() === s.path.toLowerCase())
missingRef.current = mine ? 0 : missingRef.current + 1
if (missingRef.current >= 2) {
endedByServer(d.blocked ? `An admin stopped your stream. Publishing is paused for your account until ${new Date(d.blocked.until).toLocaleTimeString()}.`
: 'The server ended the stream.')
}
} catch {}
}, 5000)
return () => clearInterval(t)
}, [phase])
const rememberTcp = on => { setPreferTcp(on); try { localStorage.setItem('vg.golive.tcp', on ? '1' : '0') } catch {} }
// Lossy uplink: re-publish the same tracks over TCP. One new recording file (the sweep stitches it), ~2 s
// of picture lost during the swap, and from then on no packet loss can reach the recorder. Never flips back:
// the latency cost of TCP is a nuisance, a corrupted recording is a loss.
const switchToTcp = async (loss) => {
const old = sessionRef.current; if (!old || switchingRef.current || old.transport === 'tcp') return
switchingRef.current = true
setConn(`loss ${loss.pct.toFixed(1)}% — switching to TCP…`)
try {
await old.release()
const next = await publish({ stream: streamRef.current, path: old.path, user: old.user, pass: old.pass, onState: onConn, transport: 'tcp' })
sessionRef.current = next; samplesRef.current = []
setTransport(next.transport)
if (next.transport === 'tcp') { rememberTcp(true); setSwitched(`switched to TCP after ${loss.pct.toFixed(1)}% loss — your next streams start on TCP`) }
else setSwitched('server offered no TCP candidate; still on UDP')
} catch (e) { setErr(`re-publish over TCP failed: ${e.message}`); setPhase('error') }
finally { switchingRef.current = false }
}
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])
@@ -25,11 +100,21 @@ export default function GoLive({ onExit }) {
const t = setInterval(async () => {
const pc = sessionRef.current?.pc; if (!pc) return
const report = await pc.getStats()
let codec = ''
let codec = '', lost = 0, sent = 0
for (const s of report) if (s.type === 'remote-inbound-rtp' && s.kind === 'video') lost = s.packetsLost || 0 // what the server reports back
for (const s of report) if (s.type === 'outbound-rtp' && s.kind === 'video') {
sent = s.packetsSent || 0
const now = Date.now(); const kbps = Math.round((s.bytesSent - last.bytes) * 8 / ((now - last.t) || 1)); last = { bytes: s.bytesSent, t: now }
const c = s.codecId && report.get(s.codecId); codec = c ? `${c.mimeType.replace('video/', '')}${/profile-level-id=64/i.test(c.sdpFmtpLine || '') ? ' High' : /profile-level-id=4d/i.test(c.sdpFmtpLine || '') ? ' Main' : /profile-level-id=42/i.test(c.sdpFmtpLine || '') ? ' Baseline' : ''}` : ''
setStats({ kbps, fps: Math.round(s.framesPerSecond || 0), w: s.frameWidth || 0, h: s.frameHeight || 0, q: s.qualityLimitationReason, enc: s.encoderImplementation || '', codec })
// nack = retransmit requests the server sent us; rtx = packets we re-sent. Both 0 under loss means the
// server is not asking for retransmits, and every lost packet is a corrupted frame in the recording.
setStats({ kbps, fps: Math.round(s.framesPerSecond || 0), w: s.frameWidth || 0, h: s.frameHeight || 0, q: s.qualityLimitationReason, enc: s.encoderImplementation || '', codec,
lost, sent, nack: s.nackCount || 0, rtx: s.retransmittedPacketsSent || 0 })
}
if (sent && !switchingRef.current) {
const samples = samplesRef.current; samples.push({ t: Date.now(), lost, sent }); if (samples.length > 60) samples.shift()
const l = windowLoss(samples)
if (sessionRef.current?.transport === 'udp' && shouldSwitchToTcp(l)) switchToTcp(l)
}
}, 1000)
return () => clearInterval(t)
@@ -41,19 +126,25 @@ export default function GoLive({ onExit }) {
const t = cleanTitle(title); if (!t) return setErr('give it a title')
if (!key) return setErr('no stream key')
try {
// blocked by an admin? MediaMTX would answer the WHIP with a bare 401, which reads like a bad key — ask first
try { const h = await (await fetch('/me/stream-health', { credentials: 'same-origin' })).json()
if (h.blocked) return setErr(`Publishing is paused for your account by an admin until ${new Date(h.blocked.until).toLocaleTimeString()}.`) } catch {}
setPhase('capturing')
const captured = await captureWindow()
const captured = await captureWindow(surface)
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(() => {}) }
const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: setConn })
sessionRef.current = session
streamRef.current = stream; samplesRef.current = []; setSwitched('')
setEnded('')
const session = await publish({ stream, path: `${key.user}/${t}`, user: key.user, pass: key.pass, onState: onConn, transport: preferTcp ? 'tcp' : 'udp' })
sessionRef.current = session; setTransport(session.transport)
if (preferTcp && session.transport !== 'tcp') setSwitched('server offered no TCP candidate; on UDP')
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; bandRef.current?.stop(); bandRef.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; streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; bandRef.current?.stop(); bandRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setPhase('setup'); setStarted(null); setStats(null); setConn(''); setTransport('udp'); setSwitched('') }
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 (
@@ -69,7 +160,7 @@ export default function GoLive({ onExit }) {
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
{phase !== 'live' && (
<div className="golive-form">
<label>Stream title <small>(becomes the Jellyfin title; letters, digits, _ . - ; no double underscore)</small></label>
<label>Stream title <small>(becomes the Jellyfin title; letters, digits, _ - ; no double underscore)</small></label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
<div className="hint" style={{marginTop: '-4px'}}>stream name: <b>{key ? key.user : '<you>'}/{cleanTitle(title) || '—'}</b></div>
<div className="hint">
@@ -78,11 +169,28 @@ 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>
<div className="hint" style={{display:'flex',gap:16,alignItems:'center',flexWrap:'wrap'}}>
<span>Capture:</span>
<label style={{display:'flex',gap:6,alignItems:'center'}}><input type="radio" name="surface" checked={surface === 'window'} onChange={() => pickSurface('window')} /> the game's window</label>
<label style={{display:'flex',gap:6,alignItems:'center'}}><input type="radio" name="surface" checked={surface === 'screen'} onChange={() => pickSurface('screen')} /> the whole screen</label>
</div>
<div className="hint">
<b>Frozen picture that only updates when you alt-tab?</b> The game is in <i>exclusive</i> full-screen, which
window capture cannot see. Either set the game to <b>borderless / windowed full-screen</b> (best: keeps
window audio), or pick <b>the whole screen</b> above (always works; carries system audio, and shows
whatever else is on that monitor).
</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>
<label className="hint" style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="checkbox" checked={preferTcp} onChange={e => rememberTcp(e.target.checked)} />
<span>Lossy uplink (Starlink, cellular, bad wifi): send over TCP from the start. Nothing gets lost on the
way to the recording; the cost is a second or two of extra delay for viewers. Ticks itself the first
time a stream has to switch.</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
@@ -90,7 +198,8 @@ export default function GoLive({ onExit }) {
should be thrown away (3-day undo).
</div>
{err && <div className="err">{err}</div>}
<button className="primary" disabled={!key || phase === 'capturing'} onClick={start}>{phase === 'capturing' ? 'Waiting for the window picker…' : 'Start'}</button>
{ended && <div className="warn-box"><b>Stream ended.</b> {ended} {/paused/.test(ended) ? 'Starting again before then will be refused.' : 'You can start again.'}</div>}
<button className="primary" disabled={!key || phase === 'capturing'} onClick={start}>{phase === 'capturing' ? 'Waiting for the picker…' : 'Start'}</button>
</div>
)}
{phase === 'live' && (
@@ -99,8 +208,9 @@ export default function GoLive({ onExit }) {
<span className="rec" title="this stream is being recorded to the server">● REC</span>
<span>{mmss(elapsed)}</span>
<span>{key.user}/{cleanTitle(title)}</span>
<span>{conn}</span>
{stats && <span>{stats.w}×{stats.h} · {stats.fps} fps · {(stats.kbps / 1000).toFixed(1)} Mbps · {stats.codec}{stats.enc ? ` (${/openh264|libvpx|software|SimulcastEncoderAdapter/i.test(stats.enc) ? 'software' : 'hardware'}: ${stats.enc})` : ''}{stats.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}</span>}
<span>{conn}{transport === 'tcp' ? ' · via TCP' : ''}</span>
{switched && <span className="warn" title="packet loss on your uplink was corrupting the recording; TCP retransmits everything, at the cost of some latency">{switched}</span>}
{stats && <span>{stats.w}×{stats.h} · {stats.fps} fps · {(stats.kbps / 1000).toFixed(1)} Mbps · {stats.codec}{stats.enc ? ` (${/openh264|libvpx|software|SimulcastEncoderAdapter/i.test(stats.enc) ? 'software' : 'hardware'}: ${stats.enc})` : ''}{stats.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}{stats.sent ? ` · loss ${(100 * stats.lost / stats.sent).toFixed(2)}% (${stats.nack} nack, ${stats.rtx} resent)` : ''}</span>}
{!hasAudio && <span className="warn">no audio captured — restart and tick "Share audio"</span>}
</div>
)}

View File

@@ -10,18 +10,25 @@ export function defaultTitle(now = new Date()) {
return `HelmetCam-${d}`
}
// Must match the dock and the server: the sweep publishes <Title>__<timestamp>.mp4, so "__" is reserved.
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '').replace(/_{2,}/g, '_').replace(/^[.-]+/, '').slice(0, 64)
// No dots: the server names the WebRTC audio mirror <title>.webrtc, so a dot in a title would let a title
// collide with a mirror name and pick up the wrong MediaMTX rule (one such collision recorded 36 GB twice).
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_-]/g, '').replace(/_{2,}/g, '_').replace(/^[.-]+/, '').slice(0, 64)
export async function captureWindow() {
// Window-only picker with that window's own audio. Chrome/Edge 141+ honour windowAudio: "window"
// (only the shared window's sound); older Chrome offers no audio for a window at all.
// surface: 'window' (default) or 'screen'. Window capture on Windows goes through the compositor, and a game in
// EXCLUSIVE full-screen bypasses the compositor: Chrome then gets a frame only when Windows composites one, i.e.
// on alt-tab — Breezy's "one frame, then another when I alt-tab". Two ways out: the game in borderless/windowed
// full-screen (compositor sees every frame), or capture the whole screen, which Chrome reads straight from the
// GPU output (desktop duplication) and which exclusive full-screen cannot hide from. Screen capture carries
// system audio instead of one window's audio.
export async function captureWindow(surface = 'window') {
const screen = surface === 'screen'
const constraints = {
video: { displaySurface: 'window', frameRate: { ideal: 60, max: 60 }, width: { max: 2560 }, height: { max: 1440 } },
video: { displaySurface: screen ? 'monitor' : 'window', frameRate: { ideal: 60, max: 60 }, width: { max: 2560 }, height: { max: 1440 } },
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: 48000, channelCount: 2 },
windowAudio: 'window', // capture the picked window's audio (Chrome 141+)
systemAudio: 'exclude',
systemAudio: screen ? 'include' : 'exclude',
selfBrowserSurface: 'exclude',
monitorTypeSurfaces: 'exclude', // hide "Entire screen": this is a single-window helmet cam
monitorTypeSurfaces: screen ? 'include' : 'exclude', // "Entire screen" only when asked for: a helmet cam is one window
surfaceSwitching: 'include',
preferCurrentTab: false,
}
@@ -40,6 +47,12 @@ export async function captureWindow() {
// skips the painfully slow cold ramp without disabling congestion control.
export const MIN_KBPS = 1200
export const START_KBPS = 9000
// On TCP nothing is lost, so the only thing Chrome's estimator sees is delay — and a Starlink dish swings its
// delay on every handoff, which reads as congestion and parks the bitrate near the floor: stable but soft.
// A higher floor is safe HERE because the failure mode a floor causes on UDP (sending into loss) cannot happen
// on TCP; if the uplink truly cannot carry it, latency grows instead of frames corrupting, which is the trade
// the TCP switch already made. Still well under a dish's typical 10-20 Mbps up.
export const TCP_MIN_KBPS = 4000
function preferCodecs(pc, kind = 'video') {
const caps = RTCRtpSender.getCapabilities?.(kind)
@@ -60,10 +73,10 @@ function preferCodecs(pc, kind = 'video') {
// Chrome honours x-google-* fmtp hints. start-bitrate skips the slow cold ramp, max-bitrate is the ceiling,
// and min-bitrate is a floor that must stay LOW: a high floor defeats congestion control entirely.
function mungeBitrates(sdp) {
function mungeBitrates(sdp, minKbps = MIN_KBPS) {
return sdp.replace(/^a=fmtp:(\d+) (.*)$/gm, (line, pt, params) =>
/packetization-mode|profile-id|VP9/i.test(params) || /H264|VP9/i.test(sdp.match(new RegExp(`a=rtpmap:${pt} .*`))?.[0] || '')
? `a=fmtp:${pt} ${params};x-google-start-bitrate=${START_KBPS};x-google-min-bitrate=${MIN_KBPS};x-google-max-bitrate=${MAX_KBPS}`
? `a=fmtp:${pt} ${params};x-google-start-bitrate=${START_KBPS};x-google-min-bitrate=${minKbps};x-google-max-bitrate=${MAX_KBPS}`
: line)
}
@@ -90,7 +103,23 @@ export function withTimecodeBand(stream) {
return { stream: out, stop: () => { running = false; clearInterval(t); video.srcObject = null } }
}
export async function publish({ stream, path, user, pass, onState }) {
// Transport. 'udp' is WebRTC as normal. 'tcp' keeps only MediaMTX's TCP candidates (webrtcLocalTCPAddress) in
// the answer, so ICE can pair with nothing else and the media rides a TCP connection: no packet loss at all,
// at the cost of latency spikes when the uplink hiccups. The recording cares about the former, not the latter.
// MediaMTX requests retransmits (NACK, pion's ConfigureNack) either way, but a wifi burst that drops more
// packets than its reorder window holds still reaches the recorder as a broken frame; TCP cannot.
const LOSS_SWITCH_PCT = 0.3 // sustained loss above this (over LOSS_WINDOW_S) flips a publisher to TCP
const LOSS_WINDOW_S = 20
const LOSS_MIN_PACKETS = 1500 // don't judge a 2-second-old session
function tcpOnly(sdp) {
const lines = sdp.split('\r\n')
const out = lines.filter(l => !(l.startsWith('a=candidate:') && / udp /i.test(l)))
if (!out.some(l => l.startsWith('a=candidate:'))) return null // no TCP candidate offered: cannot do it
return out.join('\r\n')
}
export async function publish({ stream, path, user, pass, onState, transport = 'udp' }) {
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
for (const track of stream.getTracks()) {
if (track.kind === 'video') { try { track.contentHint = 'motion' } catch {} } // game footage: favour frame rate + temporal quality
@@ -102,25 +131,34 @@ export async function publish({ stream, path, user, pass, onState }) {
const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}]
p.encodings[0].maxBitrate = MAX_KBPS * 1000 // a ceiling; congestion control may go far below it
p.encodings[0].maxFramerate = 60
// No scaleResolutionDownBy: pinning it to 1 forbids the encoder from shedding resolution under
// pressure, which leaves it nothing to give but packets it cannot deliver.
p.degradationPreference = 'balanced'
// Under pressure Chrome must shed FRAME RATE, never resolution. A resolution change is a new SPS, and
// MediaMTX opens a new recording file on every parameter change — on a lossy uplink that turned one
// stream into a row of files the sweep cannot copy-join (different sizes). Frame-rate drops cost
// nothing on the recording side. (No scaleResolutionDownBy pin: that would forbid shedding entirely.)
p.degradationPreference = 'maintain-resolution'
await vs.setParameters(p).catch(() => {})
}
pc.onconnectionstatechange = () => onState?.(pc.connectionState)
const offer = await pc.createOffer()
await pc.setLocalDescription({ type: 'offer', sdp: mungeBitrates(offer.sdp) })
await pc.setLocalDescription({ type: 'offer', sdp: mungeBitrates(offer.sdp, transport === 'tcp' ? TCP_MIN_KBPS : MIN_KBPS) })
await new Promise(res => { if (pc.iceGatheringState === 'complete') return res(); const t = setTimeout(res, 2000); pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res() } } })
// MediaMTX takes WebRTC credentials only in an Authorization header ("Bearer user:pass"). We send them as
// X-Stream-Auth and Caddy rewrites the header on the MediaMTX hop, so Authentik's gate never sees it.
const auth = { 'X-Stream-Auth': `Bearer ${user}:${pass}` }
const r = await fetch(`/webrtc/${path}/whip`, { method: 'POST', headers: { 'Content-Type': 'application/sdp', ...auth }, body: pc.localDescription.sdp, credentials: 'same-origin' })
if (!r.ok) { pc.close(); throw new Error(r.status === 401 || r.status === 403 ? 'stream key rejected' : r.status === 400 && /busy|already/i.test(await r.text().catch(() => '')) ? 'that stream name is already live (OBS running?)' : `publish failed (${r.status})`) }
await pc.setRemoteDescription({ type: 'answer', sdp: await r.text() })
let answer = await r.text()
let session = r.headers.get('Location') // MediaMTX answers relative to its own root
if (transport === 'tcp') {
const only = tcpOnly(answer)
if (!only) { transport = 'udp' } else answer = only // server offered no TCP: stay on UDP, say so in stats
}
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
if (session && session.startsWith('/') && !session.startsWith('/webrtc/')) session = '/webrtc' + session
return {
pc,
transport,
path, user, pass,
stats: async () => {
let kbps = 0, fps = 0, w = 0, h = 0
for (const s of await pc.getStats()) {
@@ -128,6 +166,11 @@ export async function publish({ stream, path, user, pass, onState }) {
}
return { kbps, fps, w, h }
},
// release: end this WHIP session but keep capturing — used to re-publish the same tracks over TCP
release: async () => {
try { if (session) await fetch(session.startsWith('http') ? '/webrtc' + new URL(session).pathname : session, { method: 'DELETE', headers: auth, credentials: 'same-origin' }) } catch {}
pc.close()
},
stop: async () => {
try { if (session) await fetch(session.startsWith('http') ? '/webrtc' + new URL(session).pathname : session, { method: 'DELETE', headers: auth, credentials: 'same-origin' }) } catch {}
pc.close()
@@ -136,6 +179,18 @@ export async function publish({ stream, path, user, pass, onState }) {
}
}
// Loss over a sliding window from the server's receiver reports. samples: [{t, lost, sent}] newest last.
export function windowLoss(samples) {
const now = Date.now()
const win = samples.filter(s => now - s.t <= LOSS_WINDOW_S * 1000)
if (win.length < 2) return null
const a = win[0], b = win[win.length - 1]
const sent = b.sent - a.sent, lost = b.lost - a.lost
if (sent < LOSS_MIN_PACKETS) return null
return { pct: 100 * Math.max(0, lost) / sent, sent, lost }
}
export const shouldSwitchToTcp = l => !!l && l.pct >= LOSS_SWITCH_PCT
export async function fetchStreamKey() {
// a fresh 12 h publish token for the logged-in Discord user (member of streamers); nothing is stored
const r = await fetch('/me/stream-token', { credentials: 'same-origin' })