From b2228d33f63b19890b4562b86811eefd763cdb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 22 Jan 2026 09:17:21 +0100 Subject: [PATCH] feat: add preferred codec support and real browser verification Add preferred codec selection for media tracks via RTCRtpTransceiver setCodecPreferences, keeping lite options separate from full options and documenting the new option in the README. Introduce a browser codec test that negotiates real media streams between peers and verifies VP9 via getStats, with Safari/WebKit skipped to avoid Playwright capability mismatches. Update Playwright launch settings to enable fake camera devices on Chromium and Firefox so the test uses getUserMedia instead of canvas streams. --- README.md | 5 ++ index.ts | 66 ++++++++++++++++- lite.ts | 6 +- test/codec-preferences.ts | 151 ++++++++++++++++++++++++++++++++++++++ vitest.browser.config.ts | 23 +++++- 5 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 test/codec-preferences.ts diff --git a/README.md b/README.md index 7c24d025..c9413fe6 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,10 @@ If `opts` is specified, then the default options (shown below) will be overridde sdpTransform: function (sdp) { return sdp }, stream: false, streams: [], + preferredCodecs: { + audio: [], + video: [] + }, trickle: true, allowHalfTrickle: false, wrtc: {}, // RTCPeerConnection/RTCSessionDescription/RTCIceCandidate @@ -291,6 +295,7 @@ The options do the following: - `sdpTransform` - function to transform the generated SDP signaling data (for advanced users) - `stream` - if video/voice is desired, pass stream returned from [`getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) - `streams` - an array of MediaStreams returned from [`getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) +- `preferredCodecs` - prefer codecs for outbound tracks (by kind), using `RTCRtpTransceiver.setCodecPreferences` where supported. Example: `{ video: ['video/AV1', 'video/VP9', 'video/VP8'], audio: ['audio/opus'] }`. If the browser does not support codec preferences (or the codec is not supported), it falls back to the browser’s default. - `trickle` - set to `false` to disable [trickle ICE](http://webrtchacks.com/trickle-ice/) and get a single 'signal' event (slower) - `wrtc` - custom webrtc implementation, mainly useful in node to specify in the [wrtc](https://npmjs.com/package/wrtc) package. Contains an object with the properties: - [`RTCPeerConnection`](https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection) diff --git a/index.ts b/index.ts index fdd1a457..7cc0bcd2 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,17 @@ /*! simple-peer. MIT License. Feross Aboukhadijeh */ -import Lite, { PeerOptions } from './lite.js' +import Lite, { PeerLiteOptions } from './lite.js' import errCode from 'err-code' import { MediaStream, MediaStreamTrack, RTCRtpSender, RTCRtpTransceiver } from 'webrtc-polyfill' +interface PreferredCodecs { + video?: string[] + audio?: string[] +} + +interface PeerOptions extends PeerLiteOptions { + preferredCodecs?: PreferredCodecs +} + /** * WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods. * Duplex stream. @@ -10,6 +19,7 @@ import { MediaStream, MediaStreamTrack, RTCRtpSender, RTCRtpTransceiver } from ' class Peer extends Lite { streams: MediaStream[] _senderMap: WeakMap> + preferredCodecs?: PreferredCodecs constructor (opts: PeerOptions = {}) { super(opts) @@ -17,6 +27,7 @@ class Peer extends Lite { this.streams = opts.streams || (opts.stream ? [opts.stream] : []) // support old "stream" option this._senderMap = new WeakMap() + this.preferredCodecs = opts.preferredCodecs if (this.streams) { this.streams.forEach(stream => { @@ -28,6 +39,47 @@ class Peer extends Lite { } } + _setPreferredCodecs (kind: 'audio' | 'video', transceiver: RTCRtpTransceiver | null): void { + const preferred = this.preferredCodecs?.[kind] + if (!preferred || preferred.length === 0) return + if (!transceiver?.setCodecPreferences) return + if (typeof RTCRtpSender.getCapabilities !== 'function') return + + const capabilities = RTCRtpSender.getCapabilities(kind) + if (!capabilities?.codecs?.length) return + + const normalized = preferred.map(codec => codec.toLowerCase()) + const ordered: RTCRtpCodecCapability[] = [] + const used = new Set() + + normalized.forEach(pref => { + const prefIsFull = pref.includes('/') + capabilities.codecs.forEach((codec, index) => { + if (used.has(index)) return + const mime = codec.mimeType?.toLowerCase() + if (!mime) return + if (mime.endsWith('/rtx') || mime.endsWith('/red') || mime.endsWith('/ulpfec')) return + if (prefIsFull ? mime === pref : mime.endsWith('/' + pref)) { + used.add(index) + ordered.push(codec) + } + }) + }) + + capabilities.codecs.forEach((codec, index) => { + if (used.has(index)) return + ordered.push(codec) + }) + + transceiver.setCodecPreferences(ordered) + } + + _getTransceiverForSender (sender: RTCRtpSender): RTCRtpTransceiver | null { + const transceivers = this._pc!.getTransceivers?.() + if (!transceivers) return null + return transceivers.find(transceiver => transceiver.sender === sender) || null + } + /** * Add a Transceiver to the connection. */ @@ -38,7 +90,10 @@ class Peer extends Lite { if (this.initiator) { try { - this._pc!.addTransceiver(kind, init as RTCRtpTransceiverInit) + const transceiver = this._pc!.addTransceiver(kind, init as RTCRtpTransceiverInit) + if (kind === 'audio' || kind === 'video') { + this._setPreferredCodecs(kind, transceiver) + } this._needsNegotiation() } catch (err) { this.__destroy(errCode(err as Error, 'ERR_ADD_TRANSCEIVER')) @@ -78,6 +133,9 @@ class Peer extends Lite { sender = this._pc!.addTrack(track, stream) submap.set(stream, sender) this._senderMap.set(track, submap) + if (track.kind === 'audio' || track.kind === 'video') { + this._setPreferredCodecs(track.kind, this._getTransceiverForSender(sender)) + } this._needsNegotiation() } else if ((sender as RTCRtpSender & { removed?: boolean }).removed) { throw errCode(new Error('Track has been removed. You should enable/disable tracks that you want to re-add.'), 'ERR_SENDER_REMOVED') @@ -185,5 +243,5 @@ class Peer extends Lite { export default Peer export { Peer } -export type { PeerOptions, SignalData, AddressInfo, StatsReport } from './lite.js' - +export type { PeerLiteOptions, SignalData, AddressInfo, StatsReport } from './lite.js' +export type { PeerOptions, PreferredCodecs } diff --git a/lite.ts b/lite.ts index c0cc2c2c..053c5022 100644 --- a/lite.ts +++ b/lite.ts @@ -33,7 +33,7 @@ interface SignalData { } } -interface PeerOptions { +interface PeerLiteOptions { initiator?: boolean channelConfig?: RTCDataChannelInit channelName?: string @@ -161,7 +161,7 @@ class Peer extends EventEmitter { static config: RTCConfiguration static channelConfig: RTCDataChannelInit - constructor (opts: PeerOptions = {}) { + constructor (opts: PeerLiteOptions = {}) { super() this.destroyed = false @@ -1045,4 +1045,4 @@ Peer.config = { Peer.channelConfig = {} export default Peer -export { Peer, PeerOptions, SignalData, AddressInfo, StatsReport } +export { Peer, PeerLiteOptions, SignalData, AddressInfo, StatsReport } diff --git a/test/codec-preferences.ts b/test/codec-preferences.ts new file mode 100644 index 00000000..286421a5 --- /dev/null +++ b/test/codec-preferences.ts @@ -0,0 +1,151 @@ +import common from './common.js' +import Peer from '../index.js' +import { test, expect } from 'vitest' + +function getVideoTransceiver (peer: Peer): RTCRtpTransceiver | null { + const transceivers = peer._pc!.getTransceivers?.() + if (!transceivers) return null + return transceivers.find(transceiver => transceiver.receiver?.track?.kind === 'video') || null +} + +function getCodecMimeTypeFromReport (report: RTCStatsReport, rtpType: 'outbound-rtp' | 'inbound-rtp'): string | null { + let rtpReport: RTCStats & { codecId?: string } | undefined + report.forEach(stat => { + if (rtpReport) return + if (stat.type !== rtpType) return + const mediaType = (stat as any).mediaType || (stat as any).kind + if (mediaType === 'video') rtpReport = stat as (RTCStats & { codecId?: string }) + }) + + if (!rtpReport?.codecId) return null + const codecReport = report.get(rtpReport.codecId) as (RTCStats & { mimeType?: string }) | undefined + return codecReport?.mimeType?.toLowerCase() ?? null +} + +function listCodecMimeTypes (report: RTCStatsReport): string[] { + const codecs: string[] = [] + report.forEach(stat => { + if (stat.type !== 'codec') return + const mime = (stat as RTCStats & { mimeType?: string }).mimeType?.toLowerCase() + if (mime && !codecs.includes(mime)) codecs.push(mime) + }) + return codecs +} + +async function waitForReceiverVideoCodec (peer: Peer, timeoutMs = 8000): Promise { + const start = Date.now() + let lastCodecs: string[] = [] + while (Date.now() - start < timeoutMs) { + const transceiver = getVideoTransceiver(peer) + if (!transceiver?.receiver?.getStats) break + const report = await transceiver.receiver.getStats() + const codec = getCodecMimeTypeFromReport(report, 'inbound-rtp') + if (codec) return codec + lastCodecs = listCodecMimeTypes(report) + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for inbound video codec. Seen codecs: ${lastCodecs.join(', ') || 'none'}`) +} + +async function waitForSenderVideoCodec (peer: Peer, timeoutMs = 8000): Promise { + const start = Date.now() + let lastCodecs: string[] = [] + while (Date.now() - start < timeoutMs) { + const transceiver = getVideoTransceiver(peer) + if (!transceiver?.sender?.getStats) break + const report = await transceiver.sender.getStats() + const codec = getCodecMimeTypeFromReport(report, 'outbound-rtp') + if (codec) return codec + lastCodecs = listCodecMimeTypes(report) + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for outbound video codec. Seen codecs: ${lastCodecs.join(', ') || 'none'}`) +} + +async function waitForVideoCodec (peer: Peer): Promise { + try { + return await waitForReceiverVideoCodec(peer, 4000) + } catch { + return await waitForSenderVideoCodec(peer, 4000) + } +} + +async function attachStreamToVideo (stream: MediaStream): Promise { + const video = document.createElement('video') + video.muted = true + video.autoplay = true + video.playsInline = true + video.srcObject = stream + document.body.appendChild(video) + try { + await video.play() + } catch { + // Ignore autoplay errors; stats should still populate. + } +} + +async function getCameraStream (): Promise { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('getUserMedia is not available in this browser') + } + return await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) +} + +test('preferredCodecs influences negotiated video codec (getStats)', async function () { + if (!process.browser) return + if (common.isBrowser('ios')) return + // Playwright WebKit does not support starting the webcam + if (common.isBrowser('safari')) return + if (typeof RTCRtpTransceiver === 'undefined') return + if (typeof RTCRtpTransceiver.prototype.setCodecPreferences !== 'function') return + if (typeof RTCRtpSender === 'undefined' || typeof RTCRtpSender.getCapabilities !== 'function') return + if (typeof RTCRtpReceiver === 'undefined' || typeof RTCRtpReceiver.getCapabilities !== 'function') return + const preferred = ['video/vp9'] + + const senderCaps = RTCRtpSender.getCapabilities('video') + const receiverCaps = RTCRtpReceiver.getCapabilities('video') + const supportsVp9 = senderCaps?.codecs?.some(codec => codec.mimeType?.toLowerCase() === 'video/vp9') && + receiverCaps?.codecs?.some(codec => codec.mimeType?.toLowerCase() === 'video/vp9') + if (!supportsVp9) return + + const [stream1, stream2] = await Promise.all([getCameraStream(), getCameraStream()]) + + const peer1 = new Peer({ + initiator: true, + streams: [stream1], + preferredCodecs: { video: preferred } + }) + const peer2 = new Peer({ + streams: [stream2], + preferredCodecs: { video: preferred } + }) + + peer1.on('signal', data => peer2.signal(data)) + peer2.on('signal', data => peer1.signal(data)) + + await new Promise((resolve) => { + let streams = 0 + const onStream = (stream: MediaStream) => { + void attachStreamToVideo(stream) + streams++ + if (streams >= 2) resolve() + } + peer1.on('stream', onStream) + peer2.on('stream', onStream) + }) + + await new Promise(resolve => setTimeout(resolve, 500)) + + const [codec1, codec2] = await Promise.all([ + waitForVideoCodec(peer1), + waitForVideoCodec(peer2) + ]) + + expect(codec1).toBe('video/vp9') + expect(codec2).toBe('video/vp9') + + peer1.destroy() + peer2.destroy() + stream1.getTracks().forEach(track => track.stop()) + stream2.getTracks().forEach(track => track.stop()) +}) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index ff2c8685..31cabcdc 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -31,9 +31,26 @@ export default defineConfig({ instances: [ // Default to chromium, but can be overridden with --browser.name flag // Supported browsers: chromium, firefox, webkit - { browser: 'chromium', provider: playwright() }, - { browser: 'firefox', provider: playwright() }, - { browser: 'webkit', provider: playwright() }, + { + browser: 'chromium', + provider: playwright({ + launchOptions: { + args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] + } + }) + }, + { + browser: 'firefox', + provider: playwright({ + launchOptions: { + firefoxUserPrefs: { + 'media.navigator.streams.fake': true, + 'media.navigator.permission.disabled': true + } + } + }) + }, + { browser: 'webkit', provider: playwright() } ], headless: true }