bunch of shit
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user