Skip to content

Commit 8676482

Browse files
committed
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.
1 parent f2b066a commit 8676482

File tree

5 files changed

+241
-10
lines changed

5 files changed

+241
-10
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,10 @@ If `opts` is specified, then the default options (shown below) will be overridde
273273
sdpTransform: function (sdp) { return sdp },
274274
stream: false,
275275
streams: [],
276+
preferredCodecs: {
277+
audio: [],
278+
video: []
279+
},
276280
trickle: true,
277281
allowHalfTrickle: false,
278282
wrtc: {}, // RTCPeerConnection/RTCSessionDescription/RTCIceCandidate
@@ -291,6 +295,7 @@ The options do the following:
291295
- `sdpTransform` - function to transform the generated SDP signaling data (for advanced users)
292296
- `stream` - if video/voice is desired, pass stream returned from [`getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
293297
- `streams` - an array of MediaStreams returned from [`getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
298+
- `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.
294299
- `trickle` - set to `false` to disable [trickle ICE](http://webrtchacks.com/trickle-ice/) and get a single 'signal' event (slower)
295300
- `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:
296301
- [`RTCPeerConnection`](https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection)

index.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
/*! simple-peer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
2-
import Lite, { PeerOptions } from './lite.js'
2+
import Lite, { PeerLiteOptions } from './lite.js'
33
import errCode from 'err-code'
44
import { MediaStream, MediaStreamTrack, RTCRtpSender, RTCRtpTransceiver } from 'webrtc-polyfill'
55

6+
interface PreferredCodecs {
7+
video?: string[]
8+
audio?: string[]
9+
}
10+
11+
interface PeerOptions extends PeerLiteOptions {
12+
preferredCodecs?: PreferredCodecs
13+
}
14+
615
/**
716
* WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods.
817
* Duplex stream.
918
*/
1019
class Peer extends Lite {
1120
streams: MediaStream[]
1221
_senderMap: WeakMap<MediaStreamTrack, WeakMap<MediaStream, RTCRtpSender>>
22+
preferredCodecs?: PreferredCodecs
1323

1424
constructor (opts: PeerOptions = {}) {
1525
super(opts)
1626
if (!this._pc) return
1727

1828
this.streams = opts.streams || (opts.stream ? [opts.stream] : []) // support old "stream" option
1929
this._senderMap = new WeakMap()
30+
this.preferredCodecs = opts.preferredCodecs
2031

2132
if (this.streams) {
2233
this.streams.forEach(stream => {
@@ -28,6 +39,47 @@ class Peer extends Lite {
2839
}
2940
}
3041

42+
_setPreferredCodecs (kind: 'audio' | 'video', transceiver: RTCRtpTransceiver | null): void {
43+
const preferred = this.preferredCodecs?.[kind]
44+
if (!preferred || preferred.length === 0) return
45+
if (!transceiver?.setCodecPreferences) return
46+
if (typeof RTCRtpSender.getCapabilities !== 'function') return
47+
48+
const capabilities = RTCRtpSender.getCapabilities(kind)
49+
if (!capabilities?.codecs?.length) return
50+
51+
const normalized = preferred.map(codec => codec.toLowerCase())
52+
const ordered: RTCRtpCodecCapability[] = []
53+
const used = new Set<number>()
54+
55+
normalized.forEach(pref => {
56+
const prefIsFull = pref.includes('/')
57+
capabilities.codecs.forEach((codec, index) => {
58+
if (used.has(index)) return
59+
const mime = codec.mimeType?.toLowerCase()
60+
if (!mime) return
61+
if (mime.endsWith('/rtx') || mime.endsWith('/red') || mime.endsWith('/ulpfec')) return
62+
if (prefIsFull ? mime === pref : mime.endsWith('/' + pref)) {
63+
used.add(index)
64+
ordered.push(codec)
65+
}
66+
})
67+
})
68+
69+
capabilities.codecs.forEach((codec, index) => {
70+
if (used.has(index)) return
71+
ordered.push(codec)
72+
})
73+
74+
transceiver.setCodecPreferences(ordered)
75+
}
76+
77+
_getTransceiverForSender (sender: RTCRtpSender): RTCRtpTransceiver | null {
78+
const transceivers = this._pc!.getTransceivers?.()
79+
if (!transceivers) return null
80+
return transceivers.find(transceiver => transceiver.sender === sender) || null
81+
}
82+
3183
/**
3284
* Add a Transceiver to the connection.
3385
*/
@@ -38,7 +90,10 @@ class Peer extends Lite {
3890

3991
if (this.initiator) {
4092
try {
41-
this._pc!.addTransceiver(kind, init as RTCRtpTransceiverInit)
93+
const transceiver = this._pc!.addTransceiver(kind, init as RTCRtpTransceiverInit)
94+
if (kind === 'audio' || kind === 'video') {
95+
this._setPreferredCodecs(kind, transceiver)
96+
}
4297
this._needsNegotiation()
4398
} catch (err) {
4499
this.__destroy(errCode(err as Error, 'ERR_ADD_TRANSCEIVER'))
@@ -78,6 +133,9 @@ class Peer extends Lite {
78133
sender = this._pc!.addTrack(track, stream)
79134
submap.set(stream, sender)
80135
this._senderMap.set(track, submap)
136+
if (track.kind === 'audio' || track.kind === 'video') {
137+
this._setPreferredCodecs(track.kind, this._getTransceiverForSender(sender))
138+
}
81139
this._needsNegotiation()
82140
} else if ((sender as RTCRtpSender & { removed?: boolean }).removed) {
83141
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 {
185243

186244
export default Peer
187245
export { Peer }
188-
export type { PeerOptions, SignalData, AddressInfo, StatsReport } from './lite.js'
189-
246+
export type { PeerLiteOptions, SignalData, AddressInfo, StatsReport } from './lite.js'
247+
export type { PeerOptions, PreferredCodecs }

lite.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface SignalData {
3333
}
3434
}
3535

36-
interface PeerOptions {
36+
interface PeerLiteOptions {
3737
initiator?: boolean
3838
channelConfig?: RTCDataChannelInit
3939
channelName?: string
@@ -161,7 +161,7 @@ class Peer extends EventEmitter<PeerEvents> {
161161
static config: RTCConfiguration
162162
static channelConfig: RTCDataChannelInit
163163

164-
constructor (opts: PeerOptions = {}) {
164+
constructor (opts: PeerLiteOptions = {}) {
165165
super()
166166

167167
this.destroyed = false
@@ -1045,4 +1045,4 @@ Peer.config = {
10451045
Peer.channelConfig = {}
10461046

10471047
export default Peer
1048-
export { Peer, PeerOptions, SignalData, AddressInfo, StatsReport }
1048+
export { Peer, PeerLiteOptions, SignalData, AddressInfo, StatsReport }

test/codec-preferences.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
})

vitest.browser.config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,26 @@ export default defineConfig({
3131
instances: [
3232
// Default to chromium, but can be overridden with --browser.name flag
3333
// Supported browsers: chromium, firefox, webkit
34-
{ browser: 'chromium', provider: playwright() },
35-
{ browser: 'firefox', provider: playwright() },
36-
{ browser: 'webkit', provider: playwright() },
34+
{
35+
browser: 'chromium',
36+
provider: playwright({
37+
launchOptions: {
38+
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
39+
}
40+
})
41+
},
42+
{
43+
browser: 'firefox',
44+
provider: playwright({
45+
launchOptions: {
46+
firefoxUserPrefs: {
47+
'media.navigator.streams.fake': true,
48+
'media.navigator.permission.disabled': true
49+
}
50+
}
51+
})
52+
},
53+
{ browser: 'webkit', provider: playwright() }
3754
],
3855
headless: true
3956
}

0 commit comments

Comments
 (0)