diff --git a/live/src/GoLive.jsx b/live/src/GoLive.jsx index 1f64715..f0983fa 100644 --- a/live/src/GoLive.jsx +++ b/live/src/GoLive.jsx @@ -22,9 +22,12 @@ export default function GoLive({ onExit }) { let last = { bytes: 0, t: Date.now() } const t = setInterval(async () => { const pc = sessionRef.current?.pc; if (!pc) return - for (const s of await pc.getStats()) if (s.type === 'outbound-rtp' && s.kind === 'video') { + const report = await pc.getStats() + let codec = '' + for (const s of report) if (s.type === 'outbound-rtp' && s.kind === 'video') { const now = Date.now(); const kbps = Math.round((s.bytesSent - last.bytes) * 8 / ((now - last.t) || 1)); last = { bytes: s.bytesSent, t: now } - setStats({ kbps, fps: Math.round(s.framesPerSecond || 0), w: s.frameWidth || 0, h: s.frameHeight || 0, q: s.qualityLimitationReason }) + 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 }) } }, 1000) return () => clearInterval(t) @@ -87,7 +90,7 @@ export default function GoLive({ onExit }) { {mmss(elapsed)} {key.user}/{cleanTitle(title)} {conn} - {stats && {stats.w}×{stats.h} · {stats.fps} fps · {(stats.kbps / 1000).toFixed(1)} Mbps{stats.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}} + {stats && {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}` : ''}} {!hasAudio && no audio captured — restart and tick "Share audio"} )} diff --git a/live/src/broadcast.js b/live/src/broadcast.js index 99c8fa3..57596b8 100644 --- a/live/src/broadcast.js +++ b/live/src/broadcast.js @@ -31,31 +31,53 @@ export async function captureWindow() { } } -function preferH264(pc, kind = 'video') { +export const MIN_KBPS = 6000 +export const START_KBPS = 9000 + +function preferCodecs(pc, kind = 'video') { const caps = RTCRtpSender.getCapabilities?.(kind) if (!caps) return const tx = pc.getTransceivers().find(t => t.sender.track?.kind === kind) if (!tx?.setCodecPreferences) return - const h264 = caps.codecs.filter(c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || '')) - const rest = caps.codecs.filter(c => !/H264/i.test(c.mimeType)) - if (h264.length) tx.setCodecPreferences([...h264, ...rest]) // MediaMTX records H264 into fMP4; VP8 could not be recorded + const isH264 = c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || '') + const profile = c => (c.sdpFmtpLine || '').match(/profile-level-id=([0-9a-f]{2})/i)?.[1]?.toLowerCase() || '' + // H.264 High (64) / Main (4d) first — these come from hardware encoders and look far better on game footage + // than the software Baseline (42) encoder; then Baseline; then VP9 (recordable, re-encoded by the sweep); + // never VP8 (MediaMTX cannot write it into MP4). + const high = caps.codecs.filter(c => isH264(c) && ['64', '4d'].includes(profile(c))) + const base = caps.codecs.filter(c => isH264(c) && !['64', '4d'].includes(profile(c))) + const vp9 = caps.codecs.filter(c => /VP9/i.test(c.mimeType)) + const rest = caps.codecs.filter(c => !/H264|VP9|VP8/i.test(c.mimeType)) + tx.setCodecPreferences([...high, ...base, ...vp9, ...rest]) +} + +// Chrome honours x-google-* fmtp hints: skip the slow bandwidth ramp and never sit below MIN_KBPS. +function mungeBitrates(sdp) { + 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}` + : line) } 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()) pc.addTransceiver(track, { direction: 'sendonly' }) - preferH264(pc) + for (const track of stream.getTracks()) { + if (track.kind === 'video') { try { track.contentHint = 'motion' } catch {} } // game footage: favour frame rate + temporal quality + pc.addTransceiver(track, { direction: 'sendonly' }) + } + preferCodecs(pc) const vs = pc.getSenders().find(s => s.track?.kind === 'video') if (vs) { const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}] p.encodings[0].maxBitrate = MAX_KBPS * 1000 p.encodings[0].maxFramerate = 60 - p.degradationPreference = 'maintain-framerate' + p.encodings[0].scaleResolutionDownBy = 1 + p.degradationPreference = 'balanced' await vs.setParameters(p).catch(() => {}) } pc.onconnectionstatechange = () => onState?.(pc.connectionState) const offer = await pc.createOffer() - await pc.setLocalDescription(offer) + await pc.setLocalDescription({ type: 'offer', sdp: mungeBitrates(offer.sdp) }) 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.