|
| 1 | +import common from './common.js' |
| 2 | +import Peer from '../index.js' |
| 3 | +import { test, expect } from 'vitest' |
| 4 | + |
| 5 | +function getVideoTransceiver (peer: Peer): RTCRtpTransceiver | null { |
| 6 | + const transceivers = peer._pc!.getTransceivers?.() |
| 7 | + if (!transceivers) return null |
| 8 | + return transceivers.find(transceiver => transceiver.receiver?.track?.kind === 'video') || null |
| 9 | +} |
| 10 | + |
| 11 | +function getCodecMimeTypeFromReport (report: RTCStatsReport, rtpType: 'outbound-rtp' | 'inbound-rtp'): string | null { |
| 12 | + let rtpReport: RTCStats & { codecId?: string } | undefined |
| 13 | + report.forEach(stat => { |
| 14 | + if (rtpReport) return |
| 15 | + if (stat.type !== rtpType) return |
| 16 | + const mediaType = (stat as any).mediaType || (stat as any).kind |
| 17 | + if (mediaType === 'video') rtpReport = stat as (RTCStats & { codecId?: string }) |
| 18 | + }) |
| 19 | + |
| 20 | + if (!rtpReport?.codecId) return null |
| 21 | + const codecReport = report.get(rtpReport.codecId) as (RTCStats & { mimeType?: string }) | undefined |
| 22 | + return codecReport?.mimeType?.toLowerCase() ?? null |
| 23 | +} |
| 24 | + |
| 25 | +function listCodecMimeTypes (report: RTCStatsReport): string[] { |
| 26 | + const codecs: string[] = [] |
| 27 | + report.forEach(stat => { |
| 28 | + if (stat.type !== 'codec') return |
| 29 | + const mime = (stat as RTCStats & { mimeType?: string }).mimeType?.toLowerCase() |
| 30 | + if (mime && !codecs.includes(mime)) codecs.push(mime) |
| 31 | + }) |
| 32 | + return codecs |
| 33 | +} |
| 34 | + |
| 35 | +async function waitForReceiverVideoCodec (peer: Peer, timeoutMs = 8000): Promise<string> { |
| 36 | + const start = Date.now() |
| 37 | + let lastCodecs: string[] = [] |
| 38 | + while (Date.now() - start < timeoutMs) { |
| 39 | + const transceiver = getVideoTransceiver(peer) |
| 40 | + if (!transceiver?.receiver?.getStats) break |
| 41 | + const report = await transceiver.receiver.getStats() |
| 42 | + const codec = getCodecMimeTypeFromReport(report, 'inbound-rtp') |
| 43 | + if (codec) return codec |
| 44 | + lastCodecs = listCodecMimeTypes(report) |
| 45 | + await new Promise(resolve => setTimeout(resolve, 100)) |
| 46 | + } |
| 47 | + throw new Error(`Timed out waiting for inbound video codec. Seen codecs: ${lastCodecs.join(', ') || 'none'}`) |
| 48 | +} |
| 49 | + |
| 50 | +async function waitForSenderVideoCodec (peer: Peer, timeoutMs = 8000): Promise<string> { |
| 51 | + const start = Date.now() |
| 52 | + let lastCodecs: string[] = [] |
| 53 | + while (Date.now() - start < timeoutMs) { |
| 54 | + const transceiver = getVideoTransceiver(peer) |
| 55 | + if (!transceiver?.sender?.getStats) break |
| 56 | + const report = await transceiver.sender.getStats() |
| 57 | + const codec = getCodecMimeTypeFromReport(report, 'outbound-rtp') |
| 58 | + if (codec) return codec |
| 59 | + lastCodecs = listCodecMimeTypes(report) |
| 60 | + await new Promise(resolve => setTimeout(resolve, 100)) |
| 61 | + } |
| 62 | + throw new Error(`Timed out waiting for outbound video codec. Seen codecs: ${lastCodecs.join(', ') || 'none'}`) |
| 63 | +} |
| 64 | + |
| 65 | +async function waitForVideoCodec (peer: Peer): Promise<string> { |
| 66 | + try { |
| 67 | + return await waitForReceiverVideoCodec(peer, 4000) |
| 68 | + } catch { |
| 69 | + return await waitForSenderVideoCodec(peer, 4000) |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +async function attachStreamToVideo (stream: MediaStream): Promise<void> { |
| 74 | + const video = document.createElement('video') |
| 75 | + video.muted = true |
| 76 | + video.autoplay = true |
| 77 | + video.playsInline = true |
| 78 | + video.srcObject = stream |
| 79 | + document.body.appendChild(video) |
| 80 | + try { |
| 81 | + await video.play() |
| 82 | + } catch { |
| 83 | + // Ignore autoplay errors; stats should still populate. |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +async function getCameraStream (): Promise<MediaStream> { |
| 88 | + if (!navigator.mediaDevices?.getUserMedia) { |
| 89 | + throw new Error('getUserMedia is not available in this browser') |
| 90 | + } |
| 91 | + return await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) |
| 92 | +} |
| 93 | + |
| 94 | +test('preferredCodecs influences negotiated video codec (getStats)', async function () { |
| 95 | + if (!process.browser) return |
| 96 | + if (common.isBrowser('ios')) return |
| 97 | + // Playwright WebKit does not support starting the webcam |
| 98 | + if (common.isBrowser('safari')) return |
| 99 | + if (typeof RTCRtpTransceiver === 'undefined') return |
| 100 | + if (typeof RTCRtpTransceiver.prototype.setCodecPreferences !== 'function') return |
| 101 | + if (typeof RTCRtpSender === 'undefined' || typeof RTCRtpSender.getCapabilities !== 'function') return |
| 102 | + if (typeof RTCRtpReceiver === 'undefined' || typeof RTCRtpReceiver.getCapabilities !== 'function') return |
| 103 | + const preferred = ['video/vp9'] |
| 104 | + |
| 105 | + const senderCaps = RTCRtpSender.getCapabilities('video') |
| 106 | + const receiverCaps = RTCRtpReceiver.getCapabilities('video') |
| 107 | + const supportsVp9 = senderCaps?.codecs?.some(codec => codec.mimeType?.toLowerCase() === 'video/vp9') && |
| 108 | + receiverCaps?.codecs?.some(codec => codec.mimeType?.toLowerCase() === 'video/vp9') |
| 109 | + if (!supportsVp9) return |
| 110 | + |
| 111 | + const [stream1, stream2] = await Promise.all([getCameraStream(), getCameraStream()]) |
| 112 | + |
| 113 | + const peer1 = new Peer({ |
| 114 | + initiator: true, |
| 115 | + streams: [stream1], |
| 116 | + preferredCodecs: { video: preferred } |
| 117 | + }) |
| 118 | + const peer2 = new Peer({ |
| 119 | + streams: [stream2], |
| 120 | + preferredCodecs: { video: preferred } |
| 121 | + }) |
| 122 | + |
| 123 | + peer1.on('signal', data => peer2.signal(data)) |
| 124 | + peer2.on('signal', data => peer1.signal(data)) |
| 125 | + |
| 126 | + await new Promise<void>((resolve) => { |
| 127 | + let streams = 0 |
| 128 | + const onStream = (stream: MediaStream) => { |
| 129 | + void attachStreamToVideo(stream) |
| 130 | + streams++ |
| 131 | + if (streams >= 2) resolve() |
| 132 | + } |
| 133 | + peer1.on('stream', onStream) |
| 134 | + peer2.on('stream', onStream) |
| 135 | + }) |
| 136 | + |
| 137 | + await new Promise(resolve => setTimeout(resolve, 500)) |
| 138 | + |
| 139 | + const [codec1, codec2] = await Promise.all([ |
| 140 | + waitForVideoCodec(peer1), |
| 141 | + waitForVideoCodec(peer2) |
| 142 | + ]) |
| 143 | + |
| 144 | + expect(codec1).toBe('video/vp9') |
| 145 | + expect(codec2).toBe('video/vp9') |
| 146 | + |
| 147 | + peer1.destroy() |
| 148 | + peer2.destroy() |
| 149 | + stream1.getTracks().forEach(track => track.stop()) |
| 150 | + stream2.getTracks().forEach(track => track.stop()) |
| 151 | +}) |
0 commit comments