2026-07-10 19:06:43 +00:00
|
|
|
// Minimal WHIP (WebRTC-HTTP Ingestion Protocol) publisher for MediaMTX.
|
|
|
|
|
|
|
|
|
|
export interface WhipSession {
|
|
|
|
|
pc: RTCPeerConnection;
|
|
|
|
|
stop: () => Promise<void>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function publishWhip(
|
|
|
|
|
whipUrl: string,
|
|
|
|
|
bearer: string,
|
|
|
|
|
stream: MediaStream,
|
|
|
|
|
): Promise<WhipSession> {
|
|
|
|
|
const pc = new RTCPeerConnection();
|
|
|
|
|
for (const track of stream.getTracks()) {
|
2026-08-11 10:31:52 +00:00
|
|
|
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/<id>/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]);
|
|
|
|
|
}
|
2026-07-10 19:06:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|