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.