Go Live quality: prefer H.264 High/Main (hardware) then Baseline then VP9; start/min bitrate hints to skip the ramp; motion content hint; show encoder + profile in stats

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:33:45 -05:00
parent e02accb855
commit e4656b9293
2 changed files with 36 additions and 11 deletions

View File

@@ -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 }) {
<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.q && stats.q !== 'none' ? ` · limited by ${stats.q}` : ''}</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>}
{!hasAudio && <span className="warn">no audio captured — restart and tick "Share audio"</span>}
</div>
)}

View File

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