Confirmed live in mediamtx logs: the HLS muxer for a browser-published stream gets created then immediately destroyed — 'the stream doesn't contain any supported codec, which are currently AV1, VP9, H265, H264, Opus, MPEG-4 Audio, KLV'. Chrome's getUserMedia()/getDisplayMedia() default video codec for WebRTC is VP8, which isn't in that list. The WHIP publish itself succeeds (stream correctly shows live, mediamtx's own API confirms bytes arriving) so this was easy to miss — only hls/live/<id>/index.m3u8 silently 404s with 'muxer is waiting to be created' forever. Fixed with RTCRtpTransceiver.setCodecPreferences(), reordering the video codec list so H264 is offered first — falls through safely if unavailable. Matches what OBS already sends over RTMP, so recording and HLS both stay on the one already-tested codec instead of gaining a second, broken one.
79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
// 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()) {
|
|
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]);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
}
|