// Minimal WHIP (WebRTC-HTTP Ingestion Protocol) publisher for MediaMTX. export interface WhipSession { pc: RTCPeerConnection; stop: () => Promise; } export async function publishWhip( whipUrl: string, bearer: string, stream: MediaStream, ): Promise { const pc = new RTCPeerConnection(); for (const track of stream.getTracks()) { const transceiver = pc.addTransceiver(track, { direction: 'sendonly' }); // Browsers default to VP8 for getUserMedia/getDisplayMedia video, which // MediaMTX's HLS output can't mux at all (confirmed live: "the stream // doesn't contain any supported codec, which are currently AV1, VP9, // H265, H264, Opus, MPEG-4 Audio, KLV" — the muxer gets created then // immediately destroyed, so the WHIP publish itself succeeds and the // stream shows as live, but hls/live//index.m3u8 permanently 404s // with "muxer is waiting to be created"). Reorder codec preference so // H264 is offered first — matches what OBS already sends over RTMP, so // this keeps a single well-tested codec through the whole pipeline // (HLS, recording) instead of introducing a second one. if (track.kind === 'video' && typeof transceiver.setCodecPreferences === 'function') { const capabilities = RTCRtpSender.getCapabilities('video'); const h264 = capabilities?.codecs.filter((c) => c.mimeType.toLowerCase() === 'video/h264') ?? []; const rest = capabilities?.codecs.filter((c) => c.mimeType.toLowerCase() !== 'video/h264') ?? []; if (h264.length > 0) transceiver.setCodecPreferences([...h264, ...rest]); } } const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await waitIceComplete(pc); const res = await fetch(whipUrl, { method: 'POST', headers: { 'content-type': 'application/sdp', authorization: `Bearer ${bearer}`, }, body: pc.localDescription!.sdp, }); if (!res.ok) { pc.close(); throw new Error(`WHIP publish failed: ${res.status} ${await res.text().catch(() => '')}`); } const location = res.headers.get('location'); const answer = await res.text(); await pc.setRemoteDescription({ type: 'answer', sdp: answer }); return { pc, stop: async () => { pc.close(); for (const track of stream.getTracks()) track.stop(); if (location) { const url = new URL(location, whipUrl).toString(); await fetch(url, { method: 'DELETE', headers: { authorization: `Bearer ${bearer}` } }).catch(() => {}); } }, }; } function waitIceComplete(pc: RTCPeerConnection): Promise { if (pc.iceGatheringState === 'complete') return Promise.resolve(); return new Promise((resolve) => { const timeout = setTimeout(resolve, 2000); // trickle fallback: send what we have pc.addEventListener('icegatheringstatechange', () => { if (pc.iceGatheringState === 'complete') { clearTimeout(timeout); resolve(); } }); }); }