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