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() }
|
let last = { bytes: 0, t: Date.now() }
|
||||||
const t = setInterval(async () => {
|
const t = setInterval(async () => {
|
||||||
const pc = sessionRef.current?.pc; if (!pc) return
|
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 }
|
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)
|
}, 1000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
@@ -87,7 +90,7 @@ export default function GoLive({ onExit }) {
|
|||||||
<span>{mmss(elapsed)}</span>
|
<span>{mmss(elapsed)}</span>
|
||||||
<span>{key.user}/{cleanTitle(title)}</span>
|
<span>{key.user}/{cleanTitle(title)}</span>
|
||||||
<span>{conn}</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>}
|
{!hasAudio && <span className="warn">no audio captured — restart and tick "Share audio"</span>}
|
||||||
</div>
|
</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)
|
const caps = RTCRtpSender.getCapabilities?.(kind)
|
||||||
if (!caps) return
|
if (!caps) return
|
||||||
const tx = pc.getTransceivers().find(t => t.sender.track?.kind === kind)
|
const tx = pc.getTransceivers().find(t => t.sender.track?.kind === kind)
|
||||||
if (!tx?.setCodecPreferences) return
|
if (!tx?.setCodecPreferences) return
|
||||||
const h264 = caps.codecs.filter(c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || ''))
|
const isH264 = c => /H264/i.test(c.mimeType) && /packetization-mode=1/.test(c.sdpFmtpLine || '')
|
||||||
const rest = caps.codecs.filter(c => !/H264/i.test(c.mimeType))
|
const profile = c => (c.sdpFmtpLine || '').match(/profile-level-id=([0-9a-f]{2})/i)?.[1]?.toLowerCase() || ''
|
||||||
if (h264.length) tx.setCodecPreferences([...h264, ...rest]) // MediaMTX records H264 into fMP4; VP8 could not be recorded
|
// 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 }) {
|
export async function publish({ stream, path, user, pass, onState }) {
|
||||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] })
|
||||||
for (const track of stream.getTracks()) pc.addTransceiver(track, { direction: 'sendonly' })
|
for (const track of stream.getTracks()) {
|
||||||
preferH264(pc)
|
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')
|
const vs = pc.getSenders().find(s => s.track?.kind === 'video')
|
||||||
if (vs) {
|
if (vs) {
|
||||||
const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}]
|
const p = vs.getParameters(); p.encodings = p.encodings?.length ? p.encodings : [{}]
|
||||||
p.encodings[0].maxBitrate = MAX_KBPS * 1000
|
p.encodings[0].maxBitrate = MAX_KBPS * 1000
|
||||||
p.encodings[0].maxFramerate = 60
|
p.encodings[0].maxFramerate = 60
|
||||||
p.degradationPreference = 'maintain-framerate'
|
p.encodings[0].scaleResolutionDownBy = 1
|
||||||
|
p.degradationPreference = 'balanced'
|
||||||
await vs.setParameters(p).catch(() => {})
|
await vs.setParameters(p).catch(() => {})
|
||||||
}
|
}
|
||||||
pc.onconnectionstatechange = () => onState?.(pc.connectionState)
|
pc.onconnectionstatechange = () => onState?.(pc.connectionState)
|
||||||
const offer = await pc.createOffer()
|
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() } } })
|
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
|
// 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.
|
// 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