Committed by
GitHub
SVC Codec support (#304)
* SDP munging * track bitrates * Switch to using android-jain-sip-ri for sdp munging * Update protocol version * add support for svc codec * Backup codec support * handle pong response * multi codec publishing * test for multicodec * Add LiveKit.init convenience function for e2e testing * spotless apply
正在显示
32 个修改的文件
包含
1532 行增加
和
140 行删除
| @@ -29,7 +29,7 @@ jobs: | @@ -29,7 +29,7 @@ jobs: | ||
| 29 | path: ./client-sdk-android | 29 | path: ./client-sdk-android |
| 30 | submodules: recursive | 30 | submodules: recursive |
| 31 | 31 | ||
| 32 | - - name: set up JDK 12 | 32 | + - name: set up JDK 17 |
| 33 | uses: actions/setup-java@v3.12.0 | 33 | uses: actions/setup-java@v3.12.0 |
| 34 | with: | 34 | with: |
| 35 | java-version: '17' | 35 | java-version: '17' |
| 1 | <component name="ProjectDictionaryState"> | 1 | <component name="ProjectDictionaryState"> |
| 2 | <dictionary name="davidliu"> | 2 | <dictionary name="davidliu"> |
| 3 | <words> | 3 | <words> |
| 4 | + <w>bitrates</w> | ||
| 4 | <w>capturer</w> | 5 | <w>capturer</w> |
| 6 | + <w>exts</w> | ||
| 7 | + <w>msid</w> | ||
| 5 | </words> | 8 | </words> |
| 6 | </dictionary> | 9 | </dictionary> |
| 7 | </component> | 10 | </component> |
| @@ -21,4 +21,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | @@ -21,4 +21,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 21 | See the License for the specific language governing permissions and | 21 | See the License for the specific language governing permissions and |
| 22 | limitations under the License. | 22 | limitations under the License. |
| 23 | 23 | ||
| 24 | -##################################################################################### | ||
| 24 | +##################################################################################### | ||
| 25 | + | ||
| 26 | +The following modifications follow MIT License from https://github.com/ggarber/sdpparser | ||
| 27 | + | ||
| 28 | +MIT License | ||
| 29 | + | ||
| 30 | +Copyright (c) 2023 Gustavo Garcia | ||
| 31 | + | ||
| 32 | +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 33 | +of this software and associated documentation files (the "Software"), to deal | ||
| 34 | +in the Software without restriction, including without limitation the rights | ||
| 35 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 36 | +copies of the Software, and to permit persons to whom the Software is | ||
| 37 | +furnished to do so, subject to the following conditions: | ||
| 38 | + | ||
| 39 | +The above copyright notice and this permission notice shall be included in all | ||
| 40 | +copies or substantial portions of the Software. | ||
| 41 | + | ||
| 42 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 43 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 44 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 45 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 46 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 47 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 48 | +SOFTWARE. |
| @@ -150,6 +150,8 @@ dependencies { | @@ -150,6 +150,8 @@ dependencies { | ||
| 150 | implementation "androidx.core:core:${versions.androidx_core}" | 150 | implementation "androidx.core:core:${versions.androidx_core}" |
| 151 | implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}" | 151 | implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}" |
| 152 | 152 | ||
| 153 | + implementation 'javax.sip:android-jain-sip-ri:1.3.0-91' | ||
| 154 | + | ||
| 153 | implementation "com.google.dagger:dagger:${versions.dagger}" | 155 | implementation "com.google.dagger:dagger:${versions.dagger}" |
| 154 | kapt "com.google.dagger:dagger-compiler:${versions.dagger}" | 156 | kapt "com.google.dagger:dagger-compiler:${versions.dagger}" |
| 155 | 157 |
| @@ -19,6 +19,7 @@ package io.livekit.android | @@ -19,6 +19,7 @@ package io.livekit.android | ||
| 19 | import android.app.Application | 19 | import android.app.Application |
| 20 | import android.content.Context | 20 | import android.content.Context |
| 21 | import io.livekit.android.dagger.DaggerLiveKitComponent | 21 | import io.livekit.android.dagger.DaggerLiveKitComponent |
| 22 | +import io.livekit.android.dagger.RTCModule | ||
| 22 | import io.livekit.android.dagger.create | 23 | import io.livekit.android.dagger.create |
| 23 | import io.livekit.android.room.ProtocolVersion | 24 | import io.livekit.android.room.ProtocolVersion |
| 24 | import io.livekit.android.room.Room | 25 | import io.livekit.android.room.Room |
| @@ -60,6 +61,16 @@ class LiveKit { | @@ -60,6 +61,16 @@ class LiveKit { | ||
| 60 | var enableWebRTCLogging: Boolean = false | 61 | var enableWebRTCLogging: Boolean = false |
| 61 | 62 | ||
| 62 | /** | 63 | /** |
| 64 | + * Certain WebRTC classes need to be initialized prior to use. | ||
| 65 | + * | ||
| 66 | + * This does not need to be called under normal circumstances, as [LiveKit.create] | ||
| 67 | + * will handle this for you. | ||
| 68 | + */ | ||
| 69 | + fun init(appContext: Context) { | ||
| 70 | + RTCModule.libWebrtcInitialization(appContext) | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + /** | ||
| 63 | * Create a Room object. | 74 | * Create a Room object. |
| 64 | */ | 75 | */ |
| 65 | fun create( | 76 | fun create( |
| @@ -93,6 +104,7 @@ class LiveKit { | @@ -93,6 +104,7 @@ class LiveKit { | ||
| 93 | room.videoTrackPublishDefaults = it | 104 | room.videoTrackPublishDefaults = it |
| 94 | } | 105 | } |
| 95 | room.adaptiveStream = options.adaptiveStream | 106 | room.adaptiveStream = options.adaptiveStream |
| 107 | + room.dynacast = options.dynacast | ||
| 96 | 108 | ||
| 97 | return room | 109 | return room |
| 98 | } | 110 | } |
| @@ -110,7 +122,7 @@ class LiveKit { | @@ -110,7 +122,7 @@ class LiveKit { | ||
| 110 | options: ConnectOptions = ConnectOptions(), | 122 | options: ConnectOptions = ConnectOptions(), |
| 111 | roomOptions: RoomOptions = RoomOptions(), | 123 | roomOptions: RoomOptions = RoomOptions(), |
| 112 | listener: RoomListener? = null, | 124 | listener: RoomListener? = null, |
| 113 | - overrides: LiveKitOverrides = LiveKitOverrides() | 125 | + overrides: LiveKitOverrides = LiveKitOverrides(), |
| 114 | ): Room { | 126 | ): Room { |
| 115 | val room = create(appContext, roomOptions, overrides) | 127 | val room = create(appContext, roomOptions, overrides) |
| 116 | 128 |
| @@ -17,6 +17,7 @@ | @@ -17,6 +17,7 @@ | ||
| 17 | package io.livekit.android.dagger | 17 | package io.livekit.android.dagger |
| 18 | 18 | ||
| 19 | import android.content.Context | 19 | import android.content.Context |
| 20 | +import android.javax.sdp.SdpFactory | ||
| 20 | import android.media.AudioAttributes | 21 | import android.media.AudioAttributes |
| 21 | import android.media.MediaRecorder | 22 | import android.media.MediaRecorder |
| 22 | import android.os.Build | 23 | import android.os.Build |
| @@ -257,6 +258,9 @@ object RTCModule { | @@ -257,6 +258,9 @@ object RTCModule { | ||
| 257 | @Provides | 258 | @Provides |
| 258 | @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) | 259 | @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) |
| 259 | fun videoHwAccel() = true | 260 | fun videoHwAccel() = true |
| 261 | + | ||
| 262 | + @Provides | ||
| 263 | + fun sdpFactory() = SdpFactory.getInstance() | ||
| 260 | } | 264 | } |
| 261 | 265 | ||
| 262 | /** | 266 | /** |
| @@ -16,6 +16,8 @@ | @@ -16,6 +16,8 @@ | ||
| 16 | 16 | ||
| 17 | package io.livekit.android.room | 17 | package io.livekit.android.room |
| 18 | 18 | ||
| 19 | +import android.javax.sdp.MediaDescription | ||
| 20 | +import android.javax.sdp.SdpFactory | ||
| 19 | import dagger.assisted.Assisted | 21 | import dagger.assisted.Assisted |
| 20 | import dagger.assisted.AssistedFactory | 22 | import dagger.assisted.AssistedFactory |
| 21 | import dagger.assisted.AssistedInject | 23 | import dagger.assisted.AssistedInject |
| @@ -24,6 +26,12 @@ import io.livekit.android.room.util.* | @@ -24,6 +26,12 @@ import io.livekit.android.room.util.* | ||
| 24 | import io.livekit.android.util.Either | 26 | import io.livekit.android.util.Either |
| 25 | import io.livekit.android.util.LKLog | 27 | import io.livekit.android.util.LKLog |
| 26 | import io.livekit.android.util.debounce | 28 | import io.livekit.android.util.debounce |
| 29 | +import io.livekit.android.webrtc.SdpExt | ||
| 30 | +import io.livekit.android.webrtc.SdpFmtp | ||
| 31 | +import io.livekit.android.webrtc.getExts | ||
| 32 | +import io.livekit.android.webrtc.getFmtps | ||
| 33 | +import io.livekit.android.webrtc.getMsid | ||
| 34 | +import io.livekit.android.webrtc.getRtps | ||
| 27 | import kotlinx.coroutines.CoroutineDispatcher | 35 | import kotlinx.coroutines.CoroutineDispatcher |
| 28 | import kotlinx.coroutines.CoroutineScope | 36 | import kotlinx.coroutines.CoroutineScope |
| 29 | import kotlinx.coroutines.SupervisorJob | 37 | import kotlinx.coroutines.SupervisorJob |
| @@ -33,6 +41,7 @@ import kotlinx.coroutines.sync.withLock | @@ -33,6 +41,7 @@ import kotlinx.coroutines.sync.withLock | ||
| 33 | import org.webrtc.* | 41 | import org.webrtc.* |
| 34 | import org.webrtc.PeerConnection.RTCConfiguration | 42 | import org.webrtc.PeerConnection.RTCConfiguration |
| 35 | import javax.inject.Named | 43 | import javax.inject.Named |
| 44 | +import kotlin.math.roundToLong | ||
| 36 | 45 | ||
| 37 | /** | 46 | /** |
| 38 | * @suppress | 47 | * @suppress |
| @@ -40,12 +49,13 @@ import javax.inject.Named | @@ -40,12 +49,13 @@ import javax.inject.Named | ||
| 40 | internal class PeerConnectionTransport | 49 | internal class PeerConnectionTransport |
| 41 | @AssistedInject | 50 | @AssistedInject |
| 42 | constructor( | 51 | constructor( |
| 43 | - @Assisted config: PeerConnection.RTCConfiguration, | 52 | + @Assisted config: RTCConfiguration, |
| 44 | @Assisted pcObserver: PeerConnection.Observer, | 53 | @Assisted pcObserver: PeerConnection.Observer, |
| 45 | @Assisted private val listener: Listener?, | 54 | @Assisted private val listener: Listener?, |
| 46 | @Named(InjectionNames.DISPATCHER_IO) | 55 | @Named(InjectionNames.DISPATCHER_IO) |
| 47 | private val ioDispatcher: CoroutineDispatcher, | 56 | private val ioDispatcher: CoroutineDispatcher, |
| 48 | connectionFactory: PeerConnectionFactory, | 57 | connectionFactory: PeerConnectionFactory, |
| 58 | + private val sdpFactory: SdpFactory, | ||
| 49 | ) { | 59 | ) { |
| 50 | private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) | 60 | private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) |
| 51 | internal val peerConnection: PeerConnection = connectionFactory.createPeerConnection( | 61 | internal val peerConnection: PeerConnection = connectionFactory.createPeerConnection( |
| @@ -59,6 +69,8 @@ constructor( | @@ -59,6 +69,8 @@ constructor( | ||
| 59 | 69 | ||
| 60 | private val mutex = Mutex() | 70 | private val mutex = Mutex() |
| 61 | 71 | ||
| 72 | + private var trackBitrates = mutableMapOf<TrackBitrateInfoKey, TrackBitrateInfo>() | ||
| 73 | + | ||
| 62 | interface Listener { | 74 | interface Listener { |
| 63 | fun onOffer(sd: SessionDescription) | 75 | fun onOffer(sd: SessionDescription) |
| 64 | } | 76 | } |
| @@ -139,9 +151,82 @@ constructor( | @@ -139,9 +151,82 @@ constructor( | ||
| 139 | } | 151 | } |
| 140 | } | 152 | } |
| 141 | 153 | ||
| 142 | - LKLog.v { "sdp offer = $sdpOffer, description: ${sdpOffer.description}, type: ${sdpOffer.type}" } | ||
| 143 | - peerConnection.setLocalDescription(sdpOffer) | ||
| 144 | - listener?.onOffer(sdpOffer) | 154 | + // munge sdp |
| 155 | + val sdpDescription = sdpFactory.createSessionDescription(sdpOffer.description) | ||
| 156 | + | ||
| 157 | + val mediaDescs = sdpDescription.getMediaDescriptions(true) | ||
| 158 | + for (mediaDesc in mediaDescs) { | ||
| 159 | + if (mediaDesc !is MediaDescription) { | ||
| 160 | + continue | ||
| 161 | + } | ||
| 162 | + if (mediaDesc.media.mediaType == "audio") { | ||
| 163 | + // TODO | ||
| 164 | + } else if (mediaDesc.media.mediaType == "video") { | ||
| 165 | + ensureVideoDDExtensionForSVC(mediaDesc) | ||
| 166 | + ensureCodecBitrates(mediaDesc, trackBitrates = trackBitrates) | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + val finalSdp = setMungedSdp(sdpOffer, sdpDescription.toString()) | ||
| 171 | + listener.onOffer(finalSdp) | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + private suspend fun setMungedSdp(sdp: SessionDescription, mungedDescription: String, remote: Boolean = false): SessionDescription { | ||
| 175 | + val mungedSdp = SessionDescription(sdp.type, mungedDescription) | ||
| 176 | + | ||
| 177 | + LKLog.v { "sdp type: ${sdp.type}\ndescription:\n${sdp.description}" } | ||
| 178 | + LKLog.v { "munged sdp type: ${mungedSdp.type}\ndescription:\n${mungedSdp.description}" } | ||
| 179 | + val mungedResult = if (remote) { | ||
| 180 | + peerConnection.setRemoteDescription(mungedSdp) | ||
| 181 | + } else { | ||
| 182 | + peerConnection.setLocalDescription(mungedSdp) | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + val mungedErrorMessage = when (mungedResult) { | ||
| 186 | + is Either.Left -> { | ||
| 187 | + // munged sdp set successfully. | ||
| 188 | + return mungedSdp | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + is Either.Right -> { | ||
| 192 | + if (mungedResult.value.isNullOrBlank()) { | ||
| 193 | + "unknown sdp error" | ||
| 194 | + } else { | ||
| 195 | + mungedResult.value | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + // munged sdp setting failed | ||
| 201 | + LKLog.w { | ||
| 202 | + "setting munged sdp for " + | ||
| 203 | + "${if (remote) "remote" else "local"} description, " + | ||
| 204 | + "${mungedSdp.type} type failed, falling back to unmodified." | ||
| 205 | + } | ||
| 206 | + LKLog.w { "error: $mungedErrorMessage" } | ||
| 207 | + | ||
| 208 | + val result = if (remote) { | ||
| 209 | + peerConnection.setRemoteDescription(sdp) | ||
| 210 | + } else { | ||
| 211 | + peerConnection.setLocalDescription(sdp) | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + if (result is Either.Right) { | ||
| 215 | + val errorMessage = if (result.value.isNullOrBlank()) { | ||
| 216 | + "unknown sdp error" | ||
| 217 | + } else { | ||
| 218 | + result.value | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + // sdp setting failed | ||
| 222 | + LKLog.w { | ||
| 223 | + "setting original sdp for " + | ||
| 224 | + "${if (remote) "remote" else "local"} description, " + | ||
| 225 | + "${sdp.type} type failed!" | ||
| 226 | + } | ||
| 227 | + LKLog.w { "error: $errorMessage" } | ||
| 228 | + } | ||
| 229 | + return sdp | ||
| 145 | } | 230 | } |
| 146 | 231 | ||
| 147 | fun prepareForIceRestart() { | 232 | fun prepareForIceRestart() { |
| @@ -156,12 +241,132 @@ constructor( | @@ -156,12 +241,132 @@ constructor( | ||
| 156 | peerConnection.setConfiguration(config) | 241 | peerConnection.setConfiguration(config) |
| 157 | } | 242 | } |
| 158 | 243 | ||
| 244 | + fun registerTrackBitrateInfo(cid: String, trackBitrateInfo: TrackBitrateInfo) { | ||
| 245 | + trackBitrates[TrackBitrateInfoKey.Cid(cid)] = trackBitrateInfo | ||
| 246 | + } | ||
| 247 | + | ||
| 248 | + fun registerTrackBitrateInfo(transceiver: RtpTransceiver, trackBitrateInfo: TrackBitrateInfo) { | ||
| 249 | + trackBitrates[TrackBitrateInfoKey.Transceiver(transceiver)] = trackBitrateInfo | ||
| 250 | + } | ||
| 251 | + | ||
| 159 | @AssistedFactory | 252 | @AssistedFactory |
| 160 | interface Factory { | 253 | interface Factory { |
| 161 | fun create( | 254 | fun create( |
| 162 | - config: PeerConnection.RTCConfiguration, | 255 | + config: RTCConfiguration, |
| 163 | pcObserver: PeerConnection.Observer, | 256 | pcObserver: PeerConnection.Observer, |
| 164 | listener: Listener?, | 257 | listener: Listener?, |
| 165 | ): PeerConnectionTransport | 258 | ): PeerConnectionTransport |
| 166 | } | 259 | } |
| 167 | } | 260 | } |
| 261 | + | ||
| 262 | +private const val DD_EXTENSION_URI = "https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension" | ||
| 263 | + | ||
| 264 | +internal fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { | ||
| 265 | + val codec = mediaDesc.getRtps() | ||
| 266 | + .firstOrNull() | ||
| 267 | + ?.second | ||
| 268 | + ?.codec ?: return | ||
| 269 | + if (!isSVCCodec(codec)) { | ||
| 270 | + return | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + var maxId = 0L | ||
| 274 | + | ||
| 275 | + val ddFound = mediaDesc.getExts().any { (_, ext) -> | ||
| 276 | + if (ext.uri == DD_EXTENSION_URI) { | ||
| 277 | + return@any true | ||
| 278 | + } | ||
| 279 | + if (ext.value > maxId) { | ||
| 280 | + maxId = ext.value | ||
| 281 | + } | ||
| 282 | + false | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + // Not found, add manually | ||
| 286 | + if (!ddFound) { | ||
| 287 | + mediaDesc.addAttribute( | ||
| 288 | + SdpExt( | ||
| 289 | + value = maxId + 1, | ||
| 290 | + uri = DD_EXTENSION_URI, | ||
| 291 | + config = null, | ||
| 292 | + direction = null, | ||
| 293 | + encryptUri = null, | ||
| 294 | + ).toAttributeField(), | ||
| 295 | + ) | ||
| 296 | + } | ||
| 297 | +} | ||
| 298 | + | ||
| 299 | +/* The svc codec (av1/vp9) would use a very low bitrate at the begining and | ||
| 300 | +increase slowly by the bandwidth estimator until it reach the target bitrate. The | ||
| 301 | +process commonly cost more than 10 seconds cause subscriber will get blur video at | ||
| 302 | +the first few seconds. So we use a 70% of target bitrate here as the start bitrate to | ||
| 303 | +eliminate this issue. | ||
| 304 | +*/ | ||
| 305 | +private const val startBitrateForSVC = 0.7 | ||
| 306 | + | ||
| 307 | +internal fun ensureCodecBitrates( | ||
| 308 | + media: MediaDescription, | ||
| 309 | + trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>, | ||
| 310 | +) { | ||
| 311 | + val msid = media.getMsid()?.value ?: return | ||
| 312 | + for ((key, trackBr) in trackBitrates) { | ||
| 313 | + if (key !is TrackBitrateInfoKey.Cid) { | ||
| 314 | + continue | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + val (cid) = key | ||
| 318 | + if (!msid.contains(cid)) { | ||
| 319 | + continue | ||
| 320 | + } | ||
| 321 | + | ||
| 322 | + val (_, rtp) = media.getRtps() | ||
| 323 | + .firstOrNull { (_, rtp) -> rtp.codec.equals(trackBr.codec, ignoreCase = true) } | ||
| 324 | + ?: continue | ||
| 325 | + val codecPayload = rtp.payload | ||
| 326 | + | ||
| 327 | + val fmtps = media.getFmtps() | ||
| 328 | + var fmtpFound = false | ||
| 329 | + for ((attribute, fmtp) in fmtps) { | ||
| 330 | + if (fmtp.payload == codecPayload) { | ||
| 331 | + fmtpFound = true | ||
| 332 | + var newFmtpConfig = fmtp.config | ||
| 333 | + if (!fmtp.config.contains("x-google-start-bitrate")) { | ||
| 334 | + newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateForSVC).roundToLong()}" | ||
| 335 | + } | ||
| 336 | + if (!fmtp.config.contains("x-google-max-bitrate")) { | ||
| 337 | + newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}" | ||
| 338 | + } | ||
| 339 | + if (fmtp.config != newFmtpConfig) { | ||
| 340 | + attribute.value = "${fmtp.payload} $newFmtpConfig" | ||
| 341 | + break | ||
| 342 | + } | ||
| 343 | + } | ||
| 344 | + } | ||
| 345 | + | ||
| 346 | + if (!fmtpFound) { | ||
| 347 | + media.addAttribute( | ||
| 348 | + SdpFmtp( | ||
| 349 | + payload = codecPayload, | ||
| 350 | + config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" + | ||
| 351 | + "x-google-max-bitrate=${trackBr.maxBitrate}", | ||
| 352 | + ).toAttributeField(), | ||
| 353 | + ) | ||
| 354 | + } | ||
| 355 | + } | ||
| 356 | +} | ||
| 357 | + | ||
| 358 | +internal fun isSVCCodec(codec: String?): Boolean { | ||
| 359 | + return codec != null && | ||
| 360 | + ("av1".equals(codec, ignoreCase = true) || | ||
| 361 | + "vp9".equals(codec, ignoreCase = true)) | ||
| 362 | +} | ||
| 363 | + | ||
| 364 | +internal data class TrackBitrateInfo( | ||
| 365 | + val codec: String, | ||
| 366 | + val maxBitrate: Long, | ||
| 367 | +) | ||
| 368 | + | ||
| 369 | +sealed class TrackBitrateInfoKey { | ||
| 370 | + data class Cid(val value: String) : TrackBitrateInfoKey() | ||
| 371 | + data class Transceiver(val value: RtpTransceiver) : TrackBitrateInfoKey() | ||
| 372 | +} |
| @@ -43,6 +43,7 @@ import livekit.LivekitRtc.JoinResponse | @@ -43,6 +43,7 @@ import livekit.LivekitRtc.JoinResponse | ||
| 43 | import livekit.LivekitRtc.ReconnectResponse | 43 | import livekit.LivekitRtc.ReconnectResponse |
| 44 | import org.webrtc.* | 44 | import org.webrtc.* |
| 45 | import org.webrtc.PeerConnection.RTCConfiguration | 45 | import org.webrtc.PeerConnection.RTCConfiguration |
| 46 | +import org.webrtc.RtpTransceiver.RtpTransceiverInit | ||
| 46 | import java.net.ConnectException | 47 | import java.net.ConnectException |
| 47 | import java.nio.ByteBuffer | 48 | import java.nio.ByteBuffer |
| 48 | import javax.inject.Inject | 49 | import javax.inject.Inject |
| @@ -278,6 +279,13 @@ internal constructor( | @@ -278,6 +279,13 @@ internal constructor( | ||
| 278 | } | 279 | } |
| 279 | } | 280 | } |
| 280 | 281 | ||
| 282 | + internal fun createSenderTransceiver( | ||
| 283 | + rtcTrack: MediaStreamTrack, | ||
| 284 | + transInit: RtpTransceiverInit, | ||
| 285 | + ): RtpTransceiver? { | ||
| 286 | + return publisher.peerConnection.addTransceiver(rtcTrack, transInit) | ||
| 287 | + } | ||
| 288 | + | ||
| 281 | fun updateSubscriptionPermissions( | 289 | fun updateSubscriptionPermissions( |
| 282 | allParticipants: Boolean, | 290 | allParticipants: Boolean, |
| 283 | participantTrackPermissions: List<ParticipantTrackPermission>, | 291 | participantTrackPermissions: List<ParticipantTrackPermission>, |
| @@ -152,8 +152,15 @@ constructor( | @@ -152,8 +152,15 @@ constructor( | ||
| 152 | * significantly reducing publishing CPU and bandwidth usage. | 152 | * significantly reducing publishing CPU and bandwidth usage. |
| 153 | * | 153 | * |
| 154 | * Defaults to false. | 154 | * Defaults to false. |
| 155 | + * | ||
| 156 | + * Will be enabled if SVC codecs (i.e. VP9/AV1) are used. Multi-codec simulcast | ||
| 157 | + * requires dynacast. | ||
| 155 | */ | 158 | */ |
| 156 | - var dynacast: Boolean = false | 159 | + var dynacast: Boolean |
| 160 | + get() = localParticipant.dynacast | ||
| 161 | + set(value) { | ||
| 162 | + localParticipant.dynacast = value | ||
| 163 | + } | ||
| 157 | 164 | ||
| 158 | /** | 165 | /** |
| 159 | * Default options to use when creating an audio track. | 166 | * Default options to use when creating an audio track. |
| @@ -175,7 +182,7 @@ constructor( | @@ -175,7 +182,7 @@ constructor( | ||
| 175 | */ | 182 | */ |
| 176 | var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults | 183 | var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults |
| 177 | 184 | ||
| 178 | - val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = dynacast).apply { | 185 | + val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply { |
| 179 | internalListener = this@Room | 186 | internalListener = this@Room |
| 180 | } | 187 | } |
| 181 | 188 |
| @@ -34,7 +34,6 @@ import kotlinx.serialization.decodeFromString | @@ -34,7 +34,6 @@ import kotlinx.serialization.decodeFromString | ||
| 34 | import kotlinx.serialization.encodeToString | 34 | import kotlinx.serialization.encodeToString |
| 35 | import kotlinx.serialization.json.Json | 35 | import kotlinx.serialization.json.Json |
| 36 | import livekit.LivekitModels | 36 | import livekit.LivekitModels |
| 37 | -import livekit.LivekitModels.Encryption | ||
| 38 | import livekit.LivekitRtc | 37 | import livekit.LivekitRtc |
| 39 | import livekit.LivekitRtc.JoinResponse | 38 | import livekit.LivekitRtc.JoinResponse |
| 40 | import livekit.LivekitRtc.ReconnectResponse | 39 | import livekit.LivekitRtc.ReconnectResponse |
| @@ -95,6 +94,7 @@ constructor( | @@ -95,6 +94,7 @@ constructor( | ||
| 95 | private var pongJob: Job? = null | 94 | private var pongJob: Job? = null |
| 96 | private var pingTimeoutDurationMillis: Long = 0 | 95 | private var pingTimeoutDurationMillis: Long = 0 |
| 97 | private var pingIntervalDurationMillis: Long = 0 | 96 | private var pingIntervalDurationMillis: Long = 0 |
| 97 | + private var rtt: Long = 0 | ||
| 98 | 98 | ||
| 99 | var connectionState: ConnectionState = ConnectionState.DISCONNECTED | 99 | var connectionState: ConnectionState = ConnectionState.DISCONNECTED |
| 100 | 100 | ||
| @@ -491,6 +491,28 @@ constructor( | @@ -491,6 +491,28 @@ constructor( | ||
| 491 | sendRequest(request) | 491 | sendRequest(request) |
| 492 | } | 492 | } |
| 493 | 493 | ||
| 494 | + fun sendPing(): Long { | ||
| 495 | + val time = Date().time | ||
| 496 | + sendRequest( | ||
| 497 | + with(LivekitRtc.SignalRequest.newBuilder()) { | ||
| 498 | + ping = time | ||
| 499 | + build() | ||
| 500 | + }, | ||
| 501 | + ) | ||
| 502 | + sendRequest( | ||
| 503 | + with(LivekitRtc.SignalRequest.newBuilder()) { | ||
| 504 | + pingReq = with(LivekitRtc.Ping.newBuilder()) { | ||
| 505 | + rtt = this@SignalClient.rtt | ||
| 506 | + timestamp = time | ||
| 507 | + build() | ||
| 508 | + } | ||
| 509 | + build() | ||
| 510 | + }, | ||
| 511 | + ) | ||
| 512 | + | ||
| 513 | + return time | ||
| 514 | + } | ||
| 515 | + | ||
| 494 | private fun sendRequest(request: LivekitRtc.SignalRequest) { | 516 | private fun sendRequest(request: LivekitRtc.SignalRequest) { |
| 495 | val skipQueue = skipQueueTypes.contains(request.messageCase) | 517 | val skipQueue = skipQueueTypes.contains(request.messageCase) |
| 496 | 518 | ||
| @@ -647,7 +669,8 @@ constructor( | @@ -647,7 +669,8 @@ constructor( | ||
| 647 | } | 669 | } |
| 648 | 670 | ||
| 649 | LivekitRtc.SignalResponse.MessageCase.PONG_RESP -> { | 671 | LivekitRtc.SignalResponse.MessageCase.PONG_RESP -> { |
| 650 | - // TODO | 672 | + rtt = Date().time - response.pongResp.lastPingTimestamp |
| 673 | + resetPingTimeout() | ||
| 651 | } | 674 | } |
| 652 | 675 | ||
| 653 | LivekitRtc.SignalResponse.MessageCase.RECONNECT -> { | 676 | LivekitRtc.SignalResponse.MessageCase.RECONNECT -> { |
| @@ -671,13 +694,7 @@ constructor( | @@ -671,13 +694,7 @@ constructor( | ||
| 671 | pingJob = coroutineScope.launch { | 694 | pingJob = coroutineScope.launch { |
| 672 | while (true) { | 695 | while (true) { |
| 673 | delay(pingIntervalDurationMillis) | 696 | delay(pingIntervalDurationMillis) |
| 674 | - | ||
| 675 | - val pingTimestamp = Date().time | ||
| 676 | - val pingRequest = LivekitRtc.SignalRequest.newBuilder() | ||
| 677 | - .setPing(pingTimestamp) | ||
| 678 | - .build() | ||
| 679 | - LKLog.v { "Sending ping: $pingTimestamp" } | ||
| 680 | - sendRequest(pingRequest) | 697 | + val pingTimestamp = sendPing() |
| 681 | startPingTimeout(pingTimestamp) | 698 | startPingTimeout(pingTimestamp) |
| 682 | } | 699 | } |
| 683 | } | 700 | } |
| @@ -29,15 +29,21 @@ import io.livekit.android.events.ParticipantEvent | @@ -29,15 +29,21 @@ import io.livekit.android.events.ParticipantEvent | ||
| 29 | import io.livekit.android.room.ConnectionState | 29 | import io.livekit.android.room.ConnectionState |
| 30 | import io.livekit.android.room.DefaultsManager | 30 | import io.livekit.android.room.DefaultsManager |
| 31 | import io.livekit.android.room.RTCEngine | 31 | import io.livekit.android.room.RTCEngine |
| 32 | +import io.livekit.android.room.TrackBitrateInfo | ||
| 33 | +import io.livekit.android.room.isSVCCodec | ||
| 32 | import io.livekit.android.room.track.* | 34 | import io.livekit.android.room.track.* |
| 33 | import io.livekit.android.room.util.EncodingUtils | 35 | import io.livekit.android.room.util.EncodingUtils |
| 34 | import io.livekit.android.util.LKLog | 36 | import io.livekit.android.util.LKLog |
| 35 | import io.livekit.android.webrtc.createStatsGetter | 37 | import io.livekit.android.webrtc.createStatsGetter |
| 38 | +import io.livekit.android.webrtc.sortVideoCodecPreferences | ||
| 36 | import kotlinx.coroutines.CoroutineDispatcher | 39 | import kotlinx.coroutines.CoroutineDispatcher |
| 40 | +import kotlinx.coroutines.launch | ||
| 37 | import livekit.LivekitModels | 41 | import livekit.LivekitModels |
| 38 | import livekit.LivekitRtc | 42 | import livekit.LivekitRtc |
| 43 | +import livekit.LivekitRtc.AddTrackRequest | ||
| 44 | +import livekit.LivekitRtc.SimulcastCodec | ||
| 39 | import org.webrtc.* | 45 | import org.webrtc.* |
| 40 | -import org.webrtc.RtpCapabilities.CodecCapability | 46 | +import org.webrtc.RtpTransceiver.RtpTransceiverInit |
| 41 | import javax.inject.Named | 47 | import javax.inject.Named |
| 42 | import kotlin.math.max | 48 | import kotlin.math.max |
| 43 | 49 | ||
| @@ -45,7 +51,7 @@ class LocalParticipant | @@ -45,7 +51,7 @@ class LocalParticipant | ||
| 45 | @AssistedInject | 51 | @AssistedInject |
| 46 | internal constructor( | 52 | internal constructor( |
| 47 | @Assisted | 53 | @Assisted |
| 48 | - private val dynacast: Boolean, | 54 | + internal var dynacast: Boolean, |
| 49 | internal val engine: RTCEngine, | 55 | internal val engine: RTCEngine, |
| 50 | private val peerConnectionFactory: PeerConnectionFactory, | 56 | private val peerConnectionFactory: PeerConnectionFactory, |
| 51 | private val context: Context, | 57 | private val context: Context, |
| @@ -180,7 +186,6 @@ internal constructor( | @@ -180,7 +186,6 @@ internal constructor( | ||
| 180 | source: Track.Source, | 186 | source: Track.Source, |
| 181 | enabled: Boolean, | 187 | enabled: Boolean, |
| 182 | mediaProjectionPermissionResultData: Intent? = null, | 188 | mediaProjectionPermissionResultData: Intent? = null, |
| 183 | - | ||
| 184 | ) { | 189 | ) { |
| 185 | val pub = getTrackPublication(source) | 190 | val pub = getTrackPublication(source) |
| 186 | if (enabled) { | 191 | if (enabled) { |
| @@ -267,9 +272,23 @@ internal constructor( | @@ -267,9 +272,23 @@ internal constructor( | ||
| 267 | options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults), | 272 | options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults), |
| 268 | publishListener: PublishListener? = null, | 273 | publishListener: PublishListener? = null, |
| 269 | ) { | 274 | ) { |
| 275 | + val isSVC = isSVCCodec(options.videoCodec) | ||
| 276 | + | ||
| 277 | + @Suppress("NAME_SHADOWING") var options = options | ||
| 278 | + if (isSVC) { | ||
| 279 | + dynacast = true | ||
| 280 | + | ||
| 281 | + // Ensure backup codec and scalability for svc codecs. | ||
| 282 | + if (options.backupCodec == null) { | ||
| 283 | + options = options.copy(backupCodec = BackupVideoCodec()) | ||
| 284 | + } | ||
| 285 | + if (options.scalabilityMode == null) { | ||
| 286 | + options = options.copy(scalabilityMode = "L3T3_KEY") | ||
| 287 | + } | ||
| 288 | + } | ||
| 270 | val encodings = computeVideoEncodings(track.dimensions, options) | 289 | val encodings = computeVideoEncodings(track.dimensions, options) |
| 271 | val videoLayers = | 290 | val videoLayers = |
| 272 | - EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings) | 291 | + EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings, isSVC) |
| 273 | 292 | ||
| 274 | publishTrackImpl( | 293 | publishTrackImpl( |
| 275 | track = track, | 294 | track = track, |
| @@ -283,6 +302,24 @@ internal constructor( | @@ -283,6 +302,24 @@ internal constructor( | ||
| 283 | LivekitModels.TrackSource.CAMERA | 302 | LivekitModels.TrackSource.CAMERA |
| 284 | } | 303 | } |
| 285 | addAllLayers(videoLayers) | 304 | addAllLayers(videoLayers) |
| 305 | + | ||
| 306 | + addSimulcastCodecs( | ||
| 307 | + with(SimulcastCodec.newBuilder()) { | ||
| 308 | + codec = options.videoCodec | ||
| 309 | + cid = track.rtcTrack.id() | ||
| 310 | + build() | ||
| 311 | + }, | ||
| 312 | + ) | ||
| 313 | + // set up backup codec | ||
| 314 | + if (options.backupCodec?.codec != null && options.videoCodec != options.backupCodec?.codec) { | ||
| 315 | + addSimulcastCodecs( | ||
| 316 | + with(SimulcastCodec.newBuilder()) { | ||
| 317 | + codec = options.backupCodec!!.codec | ||
| 318 | + cid = "" | ||
| 319 | + build() | ||
| 320 | + }, | ||
| 321 | + ) | ||
| 322 | + } | ||
| 286 | }, | 323 | }, |
| 287 | encodings = encodings, | 324 | encodings = encodings, |
| 288 | publishListener = publishListener, | 325 | publishListener = publishListener, |
| @@ -295,17 +332,21 @@ internal constructor( | @@ -295,17 +332,21 @@ internal constructor( | ||
| 295 | private suspend fun publishTrackImpl( | 332 | private suspend fun publishTrackImpl( |
| 296 | track: Track, | 333 | track: Track, |
| 297 | options: TrackPublishOptions, | 334 | options: TrackPublishOptions, |
| 298 | - requestConfig: LivekitRtc.AddTrackRequest.Builder.() -> Unit, | 335 | + requestConfig: AddTrackRequest.Builder.() -> Unit, |
| 299 | encodings: List<RtpParameters.Encoding> = emptyList(), | 336 | encodings: List<RtpParameters.Encoding> = emptyList(), |
| 300 | publishListener: PublishListener? = null, | 337 | publishListener: PublishListener? = null, |
| 301 | ): Boolean { | 338 | ): Boolean { |
| 339 | + @Suppress("NAME_SHADOWING") var options = options | ||
| 340 | + | ||
| 341 | + @Suppress("NAME_SHADOWING") var encodings = encodings | ||
| 342 | + | ||
| 302 | if (localTrackPublications.any { it.track == track }) { | 343 | if (localTrackPublications.any { it.track == track }) { |
| 303 | publishListener?.onPublishFailure(TrackException.PublishException("Track has already been published")) | 344 | publishListener?.onPublishFailure(TrackException.PublishException("Track has already been published")) |
| 304 | return false | 345 | return false |
| 305 | } | 346 | } |
| 306 | 347 | ||
| 307 | val cid = track.rtcTrack.id() | 348 | val cid = track.rtcTrack.id() |
| 308 | - val builder = LivekitRtc.AddTrackRequest.newBuilder().apply { | 349 | + val builder = AddTrackRequest.newBuilder().apply { |
| 309 | this.requestConfig() | 350 | this.requestConfig() |
| 310 | } | 351 | } |
| 311 | val trackInfo = engine.addTrack( | 352 | val trackInfo = engine.addTrack( |
| @@ -314,12 +355,30 @@ internal constructor( | @@ -314,12 +355,30 @@ internal constructor( | ||
| 314 | kind = track.kind.toProto(), | 355 | kind = track.kind.toProto(), |
| 315 | builder = builder, | 356 | builder = builder, |
| 316 | ) | 357 | ) |
| 317 | - val transInit = RtpTransceiver.RtpTransceiverInit( | 358 | + |
| 359 | + if (options is VideoTrackPublishOptions) { | ||
| 360 | + // server might not support the codec the client has requested, in that case, fallback | ||
| 361 | + // to a supported codec | ||
| 362 | + val primaryCodecMime = trackInfo.codecsList.firstOrNull()?.mimeType | ||
| 363 | + | ||
| 364 | + if (primaryCodecMime != null) { | ||
| 365 | + val updatedCodec = primaryCodecMime.mimeTypeToVideoCodec() | ||
| 366 | + if (updatedCodec != options.videoCodec) { | ||
| 367 | + LKLog.d { "falling back to server selected codec: $updatedCodec" } | ||
| 368 | + } | ||
| 369 | + options = options.copy(videoCodec = updatedCodec) | ||
| 370 | + | ||
| 371 | + // recompute encodings since bitrates/etc could have changed | ||
| 372 | + encodings = computeVideoEncodings((track as LocalVideoTrack).dimensions, options) | ||
| 373 | + } | ||
| 374 | + } | ||
| 375 | + | ||
| 376 | + val transInit = RtpTransceiverInit( | ||
| 318 | RtpTransceiver.RtpTransceiverDirection.SEND_ONLY, | 377 | RtpTransceiver.RtpTransceiverDirection.SEND_ONLY, |
| 319 | listOf(this.sid), | 378 | listOf(this.sid), |
| 320 | encodings, | 379 | encodings, |
| 321 | ) | 380 | ) |
| 322 | - val transceiver = engine.publisher.peerConnection.addTransceiver(track.rtcTrack, transInit) | 381 | + val transceiver = engine.createSenderTransceiver(track.rtcTrack, transInit) |
| 323 | 382 | ||
| 324 | when (track) { | 383 | when (track) { |
| 325 | is LocalVideoTrack -> track.transceiver = transceiver | 384 | is LocalVideoTrack -> track.transceiver = transceiver |
| @@ -336,43 +395,23 @@ internal constructor( | @@ -336,43 +395,23 @@ internal constructor( | ||
| 336 | 395 | ||
| 337 | track.statsGetter = createStatsGetter(engine.publisher.peerConnection, transceiver.sender) | 396 | track.statsGetter = createStatsGetter(engine.publisher.peerConnection, transceiver.sender) |
| 338 | 397 | ||
| 339 | - if (options is VideoTrackPublishOptions && options.videoCodec != null) { | ||
| 340 | - val targetCodec = options.videoCodec.lowercase() | ||
| 341 | - val capabilities = capabilitiesGetter(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO) | ||
| 342 | - LKLog.v { "capabilities:" } | ||
| 343 | - capabilities.codecs.forEach { codec -> | ||
| 344 | - LKLog.v { "codec: ${codec.name}, ${codec.kind}, ${codec.mimeType}, ${codec.parameters}, ${codec.preferredPayloadType}" } | 398 | + // Handle trackBitrates |
| 399 | + if (encodings.isNotEmpty()) { | ||
| 400 | + if (options is VideoTrackPublishOptions && isSVCCodec(options.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) { | ||
| 401 | + engine.publisher.registerTrackBitrateInfo( | ||
| 402 | + cid = cid, | ||
| 403 | + TrackBitrateInfo( | ||
| 404 | + codec = options.videoCodec, | ||
| 405 | + maxBitrate = (encodings.first().maxBitrateBps?.div(1000) ?: 0).toLong(), | ||
| 406 | + ), | ||
| 407 | + ) | ||
| 345 | } | 408 | } |
| 409 | + } | ||
| 346 | 410 | ||
| 347 | - val matched = mutableListOf<CodecCapability>() | ||
| 348 | - val partialMatched = mutableListOf<CodecCapability>() | ||
| 349 | - val unmatched = mutableListOf<CodecCapability>() | ||
| 350 | - | ||
| 351 | - for (codec in capabilities.codecs) { | ||
| 352 | - val mimeType = codec.mimeType.lowercase() | ||
| 353 | - if (mimeType == "audio/opus") { | ||
| 354 | - matched.add(codec) | ||
| 355 | - continue | ||
| 356 | - } | ||
| 357 | - | ||
| 358 | - if (mimeType != "video/$targetCodec") { | ||
| 359 | - unmatched.add(codec) | ||
| 360 | - continue | ||
| 361 | - } | ||
| 362 | - // for h264 codecs that have sdpFmtpLine available, use only if the | ||
| 363 | - // profile-level-id is 42e01f for cross-browser compatibility | ||
| 364 | - if (targetCodec == "h264") { | ||
| 365 | - if (codec.parameters["profile-level-id"] == "42e01f") { | ||
| 366 | - matched.add(codec) | ||
| 367 | - } else { | ||
| 368 | - partialMatched.add(codec) | ||
| 369 | - } | ||
| 370 | - continue | ||
| 371 | - } else { | ||
| 372 | - matched.add(codec) | ||
| 373 | - } | ||
| 374 | - } | ||
| 375 | - transceiver.setCodecPreferences(matched.plus(partialMatched).plus(unmatched)) | 411 | + // Set preferred video codec order |
| 412 | + if (options is VideoTrackPublishOptions) { | ||
| 413 | + transceiver.sortVideoCodecPreferences(options.videoCodec, capabilitiesGetter) | ||
| 414 | + (track as LocalVideoTrack).codec = options.videoCodec | ||
| 376 | } | 415 | } |
| 377 | 416 | ||
| 378 | val publication = LocalTrackPublication( | 417 | val publication = LocalTrackPublication( |
| @@ -397,6 +436,7 @@ internal constructor( | @@ -397,6 +436,7 @@ internal constructor( | ||
| 397 | val (width, height) = dimensions | 436 | val (width, height) = dimensions |
| 398 | var encoding = options.videoEncoding | 437 | var encoding = options.videoEncoding |
| 399 | val simulcast = options.simulcast | 438 | val simulcast = options.simulcast |
| 439 | + val scalabilityMode = options.scalabilityMode | ||
| 400 | 440 | ||
| 401 | if ((encoding == null && !simulcast) || width == 0 || height == 0) { | 441 | if ((encoding == null && !simulcast) || width == 0 || height == 0) { |
| 402 | return emptyList() | 442 | return emptyList() |
| @@ -408,7 +448,13 @@ internal constructor( | @@ -408,7 +448,13 @@ internal constructor( | ||
| 408 | } | 448 | } |
| 409 | 449 | ||
| 410 | val encodings = mutableListOf<RtpParameters.Encoding>() | 450 | val encodings = mutableListOf<RtpParameters.Encoding>() |
| 411 | - if (simulcast) { | 451 | + |
| 452 | + if (scalabilityMode != null && isSVCCodec(options.videoCodec)) { | ||
| 453 | + val rtpEncoding = encoding.toRtpEncoding() | ||
| 454 | + rtpEncoding.scalabilityMode = scalabilityMode | ||
| 455 | + encodings.add(rtpEncoding) | ||
| 456 | + return encodings | ||
| 457 | + } else if (simulcast) { | ||
| 412 | val presets = EncodingUtils.presetsForResolution(width, height) | 458 | val presets = EncodingUtils.presetsForResolution(width, height) |
| 413 | val midPreset = presets[1] | 459 | val midPreset = presets[1] |
| 414 | val lowPreset = presets[0] | 460 | val lowPreset = presets[0] |
| @@ -454,6 +500,27 @@ internal constructor( | @@ -454,6 +500,27 @@ internal constructor( | ||
| 454 | return encodings | 500 | return encodings |
| 455 | } | 501 | } |
| 456 | 502 | ||
| 503 | + private fun computeTrackBackupOptionsAndEncodings( | ||
| 504 | + track: LocalVideoTrack, | ||
| 505 | + videoCodec: VideoCodec, | ||
| 506 | + options: VideoTrackPublishOptions, | ||
| 507 | + ): Pair<VideoTrackPublishOptions, List<RtpParameters.Encoding>>? { | ||
| 508 | + if (!options.hasBackupCodec()) { | ||
| 509 | + return null | ||
| 510 | + } | ||
| 511 | + | ||
| 512 | + if (videoCodec.codecName != options.backupCodec?.codec) { | ||
| 513 | + LKLog.w { "Server requested different codec than specified backup. server: $videoCodec, specified: ${options.backupCodec?.codec}" } | ||
| 514 | + } | ||
| 515 | + | ||
| 516 | + val backupOptions = options.copy( | ||
| 517 | + videoCodec = videoCodec.codecName, | ||
| 518 | + videoEncoding = options.backupCodec!!.encoding, | ||
| 519 | + ) | ||
| 520 | + val backupEncodings = computeVideoEncodings(track.dimensions, backupOptions) | ||
| 521 | + return backupOptions to backupEncodings | ||
| 522 | + } | ||
| 523 | + | ||
| 457 | /** | 524 | /** |
| 458 | * Control who can subscribe to LocalParticipant's published tracks. | 525 | * Control who can subscribe to LocalParticipant's published tracks. |
| 459 | * | 526 | * |
| @@ -590,30 +657,87 @@ internal constructor( | @@ -590,30 +657,87 @@ internal constructor( | ||
| 590 | } | 657 | } |
| 591 | 658 | ||
| 592 | val trackSid = subscribedQualityUpdate.trackSid | 659 | val trackSid = subscribedQualityUpdate.trackSid |
| 660 | + val subscribedCodecs = subscribedQualityUpdate.subscribedCodecsList | ||
| 593 | val qualities = subscribedQualityUpdate.subscribedQualitiesList | 661 | val qualities = subscribedQualityUpdate.subscribedQualitiesList |
| 594 | - val pub = tracks[trackSid] ?: return | 662 | + val pub = tracks[trackSid] as? LocalTrackPublication ?: return |
| 595 | val track = pub.track as? LocalVideoTrack ?: return | 663 | val track = pub.track as? LocalVideoTrack ?: return |
| 596 | - | ||
| 597 | - val sender = track.transceiver?.sender ?: return | ||
| 598 | - val parameters = sender.parameters ?: return | ||
| 599 | - val encodings = parameters.encodings ?: return | ||
| 600 | - | ||
| 601 | - var hasChanged = false | ||
| 602 | - for (quality in qualities) { | ||
| 603 | - val rid = EncodingUtils.ridForVideoQuality(quality.quality) ?: continue | ||
| 604 | - val encoding = encodings.firstOrNull { it.rid == rid } | ||
| 605 | - // use low quality layer settings for non-simulcasted streams | ||
| 606 | - ?: encodings.takeIf { it.size == 1 && quality.quality == LivekitModels.VideoQuality.LOW }?.first() | ||
| 607 | - ?: continue | ||
| 608 | - if (encoding.active != quality.enabled) { | ||
| 609 | - hasChanged = true | ||
| 610 | - encoding.active = quality.enabled | ||
| 611 | - LKLog.v { "setting layer ${quality.quality} to ${quality.enabled}" } | 664 | + val options = pub.options as? VideoTrackPublishOptions ?: return |
| 665 | + | ||
| 666 | + if (subscribedCodecs.isNotEmpty()) { | ||
| 667 | + val newCodecs = track.setPublishingCodecs(subscribedCodecs) | ||
| 668 | + for (codec in newCodecs) { | ||
| 669 | + if (isBackupCodec(codec.codecName)) { | ||
| 670 | + LKLog.d { "publish $codec for $trackSid" } | ||
| 671 | + publishAdditionalCodecForTrack(track, codec, options) | ||
| 672 | + } | ||
| 612 | } | 673 | } |
| 613 | } | 674 | } |
| 675 | + if (qualities.isNotEmpty()) { | ||
| 676 | + track.setPublishingLayers(qualities) | ||
| 677 | + } | ||
| 678 | + } | ||
| 614 | 679 | ||
| 615 | - if (hasChanged) { | ||
| 616 | - sender.parameters = parameters | 680 | + private fun publishAdditionalCodecForTrack(track: LocalVideoTrack, codec: VideoCodec, options: VideoTrackPublishOptions) { |
| 681 | + val existingPublication = tracks[track.sid] ?: run { | ||
| 682 | + LKLog.w { "attempting to publish additional codec for non-published track?!" } | ||
| 683 | + return | ||
| 684 | + } | ||
| 685 | + | ||
| 686 | + val result = computeTrackBackupOptionsAndEncodings(track, codec, options) ?: run { | ||
| 687 | + LKLog.i { "backup codec has been disabled, ignoring request to add additional codec for track" } | ||
| 688 | + return | ||
| 689 | + } | ||
| 690 | + val (newOptions, newEncodings) = result | ||
| 691 | + val simulcastTrack = track.addSimulcastTrack(codec, newEncodings) | ||
| 692 | + | ||
| 693 | + val transceiverInit = RtpTransceiverInit( | ||
| 694 | + RtpTransceiver.RtpTransceiverDirection.SEND_ONLY, | ||
| 695 | + listOf(this.sid), | ||
| 696 | + newEncodings, | ||
| 697 | + ) | ||
| 698 | + | ||
| 699 | + scope.launch { | ||
| 700 | + val transceiver = engine.createSenderTransceiver(track.rtcTrack, transceiverInit) | ||
| 701 | + if (transceiver == null) { | ||
| 702 | + LKLog.w { "couldn't create new transceiver! $codec" } | ||
| 703 | + return@launch | ||
| 704 | + } | ||
| 705 | + transceiver.sortVideoCodecPreferences(newOptions.videoCodec, capabilitiesGetter) | ||
| 706 | + simulcastTrack.sender = transceiver.sender | ||
| 707 | + | ||
| 708 | + val trackRequest = AddTrackRequest.newBuilder().apply { | ||
| 709 | + cid = transceiver.sender.id() | ||
| 710 | + sid = existingPublication.sid | ||
| 711 | + type = track.kind.toProto() | ||
| 712 | + muted = !track.enabled | ||
| 713 | + source = existingPublication.source.toProto() | ||
| 714 | + addSimulcastCodecs( | ||
| 715 | + with(SimulcastCodec.newBuilder()) { | ||
| 716 | + this@with.codec = codec.codecName | ||
| 717 | + this@with.cid = transceiver.sender.id() | ||
| 718 | + build() | ||
| 719 | + }, | ||
| 720 | + ) | ||
| 721 | + addAllLayers( | ||
| 722 | + EncodingUtils.videoLayersFromEncodings( | ||
| 723 | + track.dimensions.width, | ||
| 724 | + track.dimensions.height, | ||
| 725 | + newEncodings, | ||
| 726 | + isSVCCodec(codec.codecName), | ||
| 727 | + ), | ||
| 728 | + ) | ||
| 729 | + } | ||
| 730 | + | ||
| 731 | + val trackInfo = engine.addTrack( | ||
| 732 | + cid = simulcastTrack.rtcTrack.id(), | ||
| 733 | + name = existingPublication.name, | ||
| 734 | + kind = existingPublication.kind.toProto(), | ||
| 735 | + builder = trackRequest, | ||
| 736 | + ) | ||
| 737 | + | ||
| 738 | + engine.negotiatePublisher() | ||
| 739 | + | ||
| 740 | + LKLog.d { "published $codec for track ${track.sid}, $trackInfo" } | ||
| 617 | } | 741 | } |
| 618 | } | 742 | } |
| 619 | 743 | ||
| @@ -713,21 +837,37 @@ abstract class BaseVideoTrackPublishOptions { | @@ -713,21 +837,37 @@ abstract class BaseVideoTrackPublishOptions { | ||
| 713 | 837 | ||
| 714 | /** | 838 | /** |
| 715 | * The video codec to use if available. | 839 | * The video codec to use if available. |
| 840 | + * | ||
| 841 | + * Defaults to VP8. | ||
| 842 | + * | ||
| 843 | + * @see [VideoCodec] | ||
| 716 | */ | 844 | */ |
| 717 | - abstract val videoCodec: String? | 845 | + abstract val videoCodec: String |
| 846 | + | ||
| 847 | + /** | ||
| 848 | + * scalability mode for svc codecs, defaults to 'L3T3'. | ||
| 849 | + * for svc codecs, simulcast is disabled. | ||
| 850 | + */ | ||
| 851 | + abstract val scalabilityMode: String? | ||
| 852 | + | ||
| 853 | + abstract val backupCodec: BackupVideoCodec? | ||
| 718 | } | 854 | } |
| 719 | 855 | ||
| 720 | data class VideoTrackPublishDefaults( | 856 | data class VideoTrackPublishDefaults( |
| 721 | override val videoEncoding: VideoEncoding? = null, | 857 | override val videoEncoding: VideoEncoding? = null, |
| 722 | override val simulcast: Boolean = true, | 858 | override val simulcast: Boolean = true, |
| 723 | - override val videoCodec: String? = null, | 859 | + override val videoCodec: String = VideoCodec.VP8.codecName, |
| 860 | + override val scalabilityMode: String? = null, | ||
| 861 | + override val backupCodec: BackupVideoCodec? = null, | ||
| 724 | ) : BaseVideoTrackPublishOptions() | 862 | ) : BaseVideoTrackPublishOptions() |
| 725 | 863 | ||
| 726 | data class VideoTrackPublishOptions( | 864 | data class VideoTrackPublishOptions( |
| 727 | override val name: String? = null, | 865 | override val name: String? = null, |
| 728 | override val videoEncoding: VideoEncoding? = null, | 866 | override val videoEncoding: VideoEncoding? = null, |
| 729 | override val simulcast: Boolean = true, | 867 | override val simulcast: Boolean = true, |
| 730 | - override val videoCodec: String? = null, | 868 | + override val videoCodec: String = VideoCodec.VP8.codecName, |
| 869 | + override val scalabilityMode: String? = null, | ||
| 870 | + override val backupCodec: BackupVideoCodec? = null, | ||
| 731 | ) : BaseVideoTrackPublishOptions(), TrackPublishOptions { | 871 | ) : BaseVideoTrackPublishOptions(), TrackPublishOptions { |
| 732 | constructor( | 872 | constructor( |
| 733 | name: String? = null, | 873 | name: String? = null, |
| @@ -737,9 +877,28 @@ data class VideoTrackPublishOptions( | @@ -737,9 +877,28 @@ data class VideoTrackPublishOptions( | ||
| 737 | base.videoEncoding, | 877 | base.videoEncoding, |
| 738 | base.simulcast, | 878 | base.simulcast, |
| 739 | base.videoCodec, | 879 | base.videoCodec, |
| 880 | + base.scalabilityMode, | ||
| 881 | + base.backupCodec, | ||
| 740 | ) | 882 | ) |
| 883 | + | ||
| 884 | + fun createBackupOptions(): VideoTrackPublishOptions? { | ||
| 885 | + return if (hasBackupCodec()) { | ||
| 886 | + copy( | ||
| 887 | + videoCodec = backupCodec!!.codec, | ||
| 888 | + videoEncoding = backupCodec.encoding, | ||
| 889 | + ) | ||
| 890 | + } else { | ||
| 891 | + null | ||
| 892 | + } | ||
| 893 | + } | ||
| 741 | } | 894 | } |
| 742 | 895 | ||
| 896 | +data class BackupVideoCodec( | ||
| 897 | + val codec: String = "vp8", | ||
| 898 | + val encoding: VideoEncoding? = null, | ||
| 899 | + val simulcast: Boolean = true, | ||
| 900 | +) | ||
| 901 | + | ||
| 743 | abstract class BaseAudioTrackPublishOptions { | 902 | abstract class BaseAudioTrackPublishOptions { |
| 744 | abstract val audioBitrate: Int? | 903 | abstract val audioBitrate: Int? |
| 745 | 904 | ||
| @@ -813,14 +972,9 @@ data class ParticipantTrackPermission( | @@ -813,14 +972,9 @@ data class ParticipantTrackPermission( | ||
| 813 | } | 972 | } |
| 814 | } | 973 | } |
| 815 | 974 | ||
| 816 | -sealed class PublishRecord() { | ||
| 817 | - data class AudioTrackPublishRecord( | ||
| 818 | - val track: LocalAudioTrack, | ||
| 819 | - val options: AudioTrackPublishOptions, | ||
| 820 | - ) | ||
| 821 | - | ||
| 822 | - data class VideoTrackPublishRecord( | ||
| 823 | - val track: LocalVideoTrack, | ||
| 824 | - val options: VideoTrackPublishOptions, | ||
| 825 | - ) | 975 | +internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean { |
| 976 | + return backupCodec?.codec != null && videoCodec != backupCodec.codec | ||
| 826 | } | 977 | } |
| 978 | + | ||
| 979 | +private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName) | ||
| 980 | +private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName) |
| @@ -173,6 +173,8 @@ open class Participant( | @@ -173,6 +173,8 @@ open class Participant( | ||
| 173 | get() = participantInfo != null | 173 | get() = participantInfo != null |
| 174 | 174 | ||
| 175 | /** | 175 | /** |
| 176 | + * Maps track sids to their track publications. | ||
| 177 | + * | ||
| 176 | * Changes can be observed by using [io.livekit.android.util.flow] | 178 | * Changes can be observed by using [io.livekit.android.util.flow] |
| 177 | */ | 179 | */ |
| 178 | @FlowObservable | 180 | @FlowObservable |
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room.participant | ||
| 18 | + | ||
| 19 | +fun String.mimeTypeToVideoCodec(): String { | ||
| 20 | + return split("/")[1].lowercase() | ||
| 21 | +} |
| @@ -26,16 +26,33 @@ import dagger.assisted.AssistedInject | @@ -26,16 +26,33 @@ import dagger.assisted.AssistedInject | ||
| 26 | import io.livekit.android.memory.CloseableManager | 26 | import io.livekit.android.memory.CloseableManager |
| 27 | import io.livekit.android.memory.SurfaceTextureHelperCloser | 27 | import io.livekit.android.memory.SurfaceTextureHelperCloser |
| 28 | import io.livekit.android.room.DefaultsManager | 28 | import io.livekit.android.room.DefaultsManager |
| 29 | -import io.livekit.android.room.track.video.* | 29 | +import io.livekit.android.room.track.video.CameraCapturerUtils |
| 30 | import io.livekit.android.room.track.video.CameraCapturerUtils.createCameraEnumerator | 30 | import io.livekit.android.room.track.video.CameraCapturerUtils.createCameraEnumerator |
| 31 | import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera | 31 | import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera |
| 32 | import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition | 32 | import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition |
| 33 | +import io.livekit.android.room.track.video.CameraCapturerWithSize | ||
| 34 | +import io.livekit.android.room.track.video.VideoCapturerWithSize | ||
| 35 | +import io.livekit.android.room.util.EncodingUtils | ||
| 33 | import io.livekit.android.util.FlowObservable | 36 | import io.livekit.android.util.FlowObservable |
| 34 | import io.livekit.android.util.LKLog | 37 | import io.livekit.android.util.LKLog |
| 35 | import io.livekit.android.util.flowDelegate | 38 | import io.livekit.android.util.flowDelegate |
| 36 | -import org.webrtc.* | 39 | +import livekit.LivekitModels |
| 40 | +import livekit.LivekitModels.VideoQuality | ||
| 41 | +import livekit.LivekitRtc | ||
| 42 | +import livekit.LivekitRtc.SubscribedCodec | ||
| 43 | +import org.webrtc.CameraVideoCapturer | ||
| 37 | import org.webrtc.CameraVideoCapturer.CameraEventsHandler | 44 | import org.webrtc.CameraVideoCapturer.CameraEventsHandler |
| 38 | -import java.util.* | 45 | +import org.webrtc.EglBase |
| 46 | +import org.webrtc.MediaStreamTrack | ||
| 47 | +import org.webrtc.PeerConnectionFactory | ||
| 48 | +import org.webrtc.RtpParameters | ||
| 49 | +import org.webrtc.RtpSender | ||
| 50 | +import org.webrtc.RtpTransceiver | ||
| 51 | +import org.webrtc.SurfaceTextureHelper | ||
| 52 | +import org.webrtc.VideoCapturer | ||
| 53 | +import org.webrtc.VideoProcessor | ||
| 54 | +import org.webrtc.VideoSource | ||
| 55 | +import java.util.UUID | ||
| 39 | 56 | ||
| 40 | /** | 57 | /** |
| 41 | * A representation of a local video track (generally input coming from camera or screen). | 58 | * A representation of a local video track (generally input coming from camera or screen). |
| @@ -60,6 +77,10 @@ constructor( | @@ -60,6 +77,10 @@ constructor( | ||
| 60 | override var rtcTrack: org.webrtc.VideoTrack = rtcTrack | 77 | override var rtcTrack: org.webrtc.VideoTrack = rtcTrack |
| 61 | internal set | 78 | internal set |
| 62 | 79 | ||
| 80 | + internal var codec: String? = null | ||
| 81 | + private var subscribedCodecs: List<SubscribedCodec>? = null | ||
| 82 | + private val simulcastCodecs = mutableMapOf<VideoCodec, SimulcastTrackInfo>() | ||
| 83 | + | ||
| 63 | @FlowObservable | 84 | @FlowObservable |
| 64 | @get:FlowObservable | 85 | @get:FlowObservable |
| 65 | var options: LocalVideoTrackOptions by flowDelegate(options) | 86 | var options: LocalVideoTrackOptions by flowDelegate(options) |
| @@ -69,7 +90,7 @@ constructor( | @@ -69,7 +90,7 @@ constructor( | ||
| 69 | (capturer as? VideoCapturerWithSize)?.let { capturerWithSize -> | 90 | (capturer as? VideoCapturerWithSize)?.let { capturerWithSize -> |
| 70 | val size = capturerWithSize.findCaptureFormat( | 91 | val size = capturerWithSize.findCaptureFormat( |
| 71 | options.captureParams.width, | 92 | options.captureParams.width, |
| 72 | - options.captureParams.height | 93 | + options.captureParams.height, |
| 73 | ) | 94 | ) |
| 74 | return Dimensions(size.width, size.height) | 95 | return Dimensions(size.width, size.height) |
| 75 | } | 96 | } |
| @@ -86,7 +107,7 @@ constructor( | @@ -86,7 +107,7 @@ constructor( | ||
| 86 | capturer.startCapture( | 107 | capturer.startCapture( |
| 87 | options.captureParams.width, | 108 | options.captureParams.width, |
| 88 | options.captureParams.height, | 109 | options.captureParams.height, |
| 89 | - options.captureParams.maxFps | 110 | + options.captureParams.maxFps, |
| 90 | ) | 111 | ) |
| 91 | } | 112 | } |
| 92 | 113 | ||
| @@ -140,7 +161,7 @@ constructor( | @@ -140,7 +161,7 @@ constructor( | ||
| 140 | fun updateCameraOptions() { | 161 | fun updateCameraOptions() { |
| 141 | val newOptions = options.copy( | 162 | val newOptions = options.copy( |
| 142 | deviceId = targetDeviceId, | 163 | deviceId = targetDeviceId, |
| 143 | - position = enumerator.getCameraPosition(targetDeviceId) | 164 | + position = enumerator.getCameraPosition(targetDeviceId), |
| 144 | ) | 165 | ) |
| 145 | options = newOptions | 166 | options = newOptions |
| 146 | } | 167 | } |
| @@ -150,30 +171,32 @@ constructor( | @@ -150,30 +171,32 @@ constructor( | ||
| 150 | // For cameras we control, wait until the first frame to ensure everything is okay. | 171 | // For cameras we control, wait until the first frame to ensure everything is okay. |
| 151 | if (cameraCapturer is CameraCapturerWithSize) { | 172 | if (cameraCapturer is CameraCapturerWithSize) { |
| 152 | cameraCapturer.cameraEventsDispatchHandler | 173 | cameraCapturer.cameraEventsDispatchHandler |
| 153 | - .registerHandler(object : CameraEventsHandler { | ||
| 154 | - override fun onFirstFrameAvailable() { | ||
| 155 | - updateCameraOptions() | ||
| 156 | - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 157 | - } | ||
| 158 | - | ||
| 159 | - override fun onCameraError(p0: String?) { | ||
| 160 | - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 161 | - } | ||
| 162 | - | ||
| 163 | - override fun onCameraDisconnected() { | ||
| 164 | - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 165 | - } | ||
| 166 | - | ||
| 167 | - override fun onCameraFreezed(p0: String?) { | ||
| 168 | - } | ||
| 169 | - | ||
| 170 | - override fun onCameraOpening(p0: String?) { | ||
| 171 | - } | ||
| 172 | - | ||
| 173 | - override fun onCameraClosed() { | ||
| 174 | - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 175 | - } | ||
| 176 | - }) | 174 | + .registerHandler( |
| 175 | + object : CameraEventsHandler { | ||
| 176 | + override fun onFirstFrameAvailable() { | ||
| 177 | + updateCameraOptions() | ||
| 178 | + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + override fun onCameraError(p0: String?) { | ||
| 182 | + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + override fun onCameraDisconnected() { | ||
| 186 | + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + override fun onCameraFreezed(p0: String?) { | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + override fun onCameraOpening(p0: String?) { | ||
| 193 | + } | ||
| 194 | + | ||
| 195 | + override fun onCameraClosed() { | ||
| 196 | + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) | ||
| 197 | + } | ||
| 198 | + }, | ||
| 199 | + ) | ||
| 177 | } else { | 200 | } else { |
| 178 | updateCameraOptions() | 201 | updateCameraOptions() |
| 179 | } | 202 | } |
| @@ -216,7 +239,7 @@ constructor( | @@ -216,7 +239,7 @@ constructor( | ||
| 216 | name, | 239 | name, |
| 217 | options, | 240 | options, |
| 218 | eglBase, | 241 | eglBase, |
| 219 | - trackFactory | 242 | + trackFactory, |
| 220 | ) | 243 | ) |
| 221 | 244 | ||
| 222 | // migrate video sinks to the new track | 245 | // migrate video sinks to the new track |
| @@ -233,6 +256,122 @@ constructor( | @@ -233,6 +256,122 @@ constructor( | ||
| 233 | sender?.setTrack(newTrack.rtcTrack, true) | 256 | sender?.setTrack(newTrack.rtcTrack, true) |
| 234 | } | 257 | } |
| 235 | 258 | ||
| 259 | + internal fun setPublishingLayers( | ||
| 260 | + qualities: List<LivekitRtc.SubscribedQuality>, | ||
| 261 | + ) { | ||
| 262 | + val sender = transceiver?.sender ?: return | ||
| 263 | + | ||
| 264 | + setPublishingLayersForSender(sender, qualities) | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + private fun setPublishingLayersForSender( | ||
| 268 | + sender: RtpSender, | ||
| 269 | + qualities: List<LivekitRtc.SubscribedQuality>, | ||
| 270 | + ) { | ||
| 271 | + val parameters = sender.parameters ?: return | ||
| 272 | + val encodings = parameters.encodings ?: return | ||
| 273 | + var hasChanged = false | ||
| 274 | + | ||
| 275 | + if (encodings.firstOrNull()?.scalabilityMode != null) { | ||
| 276 | + val encoding = encodings.first() | ||
| 277 | + var maxQuality = VideoQuality.OFF | ||
| 278 | + for (quality in qualities) { | ||
| 279 | + if (quality.enabled && (maxQuality == VideoQuality.OFF || quality.quality.number > maxQuality.number)) { | ||
| 280 | + maxQuality = quality.quality | ||
| 281 | + } | ||
| 282 | + } | ||
| 283 | + | ||
| 284 | + if (maxQuality == VideoQuality.OFF) { | ||
| 285 | + if (encoding.active) { | ||
| 286 | + LKLog.v { "setting svc track to disabled" } | ||
| 287 | + encoding.active = false | ||
| 288 | + hasChanged = true | ||
| 289 | + } | ||
| 290 | + } else if (!encoding.active) { | ||
| 291 | + LKLog.v { "setting svc track to enabled" } | ||
| 292 | + encoding.active = true | ||
| 293 | + hasChanged = true | ||
| 294 | + } | ||
| 295 | + } else { | ||
| 296 | + // simulcast dynacast encodings | ||
| 297 | + for (quality in qualities) { | ||
| 298 | + val rid = EncodingUtils.ridForVideoQuality(quality.quality) ?: continue | ||
| 299 | + val encoding = encodings.firstOrNull { it.rid == rid } | ||
| 300 | + // use low quality layer settings for non-simulcasted streams | ||
| 301 | + ?: encodings.takeIf { it.size == 1 && quality.quality == LivekitModels.VideoQuality.LOW }?.first() | ||
| 302 | + ?: continue | ||
| 303 | + if (encoding.active != quality.enabled) { | ||
| 304 | + hasChanged = true | ||
| 305 | + encoding.active = quality.enabled | ||
| 306 | + LKLog.v { "setting layer ${quality.quality} to ${quality.enabled}" } | ||
| 307 | + } | ||
| 308 | + } | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + if (hasChanged) { | ||
| 312 | + // This refeshes the native code with the new information | ||
| 313 | + sender.parameters = sender.parameters | ||
| 314 | + } | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + fun setPublishingCodecs(codecs: List<SubscribedCodec>): List<VideoCodec> { | ||
| 318 | + LKLog.v { "setting publishing codecs: $codecs" } | ||
| 319 | + | ||
| 320 | + // only enable simulcast codec for preferred codec set | ||
| 321 | + if (this.codec == null && codecs.isNotEmpty()) { | ||
| 322 | + setPublishingLayers(codecs.first().qualitiesList) | ||
| 323 | + return emptyList() | ||
| 324 | + } | ||
| 325 | + | ||
| 326 | + this.subscribedCodecs = codecs | ||
| 327 | + | ||
| 328 | + val newCodecs = mutableListOf<VideoCodec>() | ||
| 329 | + | ||
| 330 | + for (codec in codecs) { | ||
| 331 | + if (this.codec == codec.codec) { | ||
| 332 | + setPublishingLayers(codec.qualitiesList) | ||
| 333 | + } else { | ||
| 334 | + val videoCodec = try { | ||
| 335 | + VideoCodec.fromCodecName(codec.codec) | ||
| 336 | + } catch (e: Exception) { | ||
| 337 | + LKLog.w { "unknown publishing codec ${codec.codec}!" } | ||
| 338 | + continue | ||
| 339 | + } | ||
| 340 | + | ||
| 341 | + LKLog.d { "try setPublishingCodec for ${codec.codec}" } | ||
| 342 | + val simulcastInfo = this.simulcastCodecs[videoCodec] | ||
| 343 | + if (simulcastInfo?.sender == null) { | ||
| 344 | + for (q in codec.qualitiesList) { | ||
| 345 | + if (q.enabled) { | ||
| 346 | + newCodecs.add(videoCodec) | ||
| 347 | + break | ||
| 348 | + } | ||
| 349 | + } | ||
| 350 | + } else { | ||
| 351 | + LKLog.d { "try setPublishingLayersForSender ${codec.codec}" } | ||
| 352 | + setPublishingLayersForSender( | ||
| 353 | + simulcastInfo.sender!!, | ||
| 354 | + codec.qualitiesList, | ||
| 355 | + ) | ||
| 356 | + } | ||
| 357 | + } | ||
| 358 | + } | ||
| 359 | + return newCodecs | ||
| 360 | + } | ||
| 361 | + | ||
| 362 | + internal fun addSimulcastTrack(codec: VideoCodec, encodings: List<RtpParameters.Encoding>): SimulcastTrackInfo { | ||
| 363 | + if (this.simulcastCodecs.containsKey(codec)) { | ||
| 364 | + throw IllegalStateException("$codec already added!") | ||
| 365 | + } | ||
| 366 | + val simulcastTrackInfo = SimulcastTrackInfo( | ||
| 367 | + codec = codec.codecName, | ||
| 368 | + rtcTrack = rtcTrack, | ||
| 369 | + encodings = encodings, | ||
| 370 | + ) | ||
| 371 | + simulcastCodecs[codec] = simulcastTrackInfo | ||
| 372 | + return simulcastTrackInfo | ||
| 373 | + } | ||
| 374 | + | ||
| 236 | @AssistedFactory | 375 | @AssistedFactory |
| 237 | interface Factory { | 376 | interface Factory { |
| 238 | fun create( | 377 | fun create( |
| @@ -262,7 +401,7 @@ constructor( | @@ -262,7 +401,7 @@ constructor( | ||
| 262 | capturer.initialize( | 401 | capturer.initialize( |
| 263 | surfaceTextureHelper, | 402 | surfaceTextureHelper, |
| 264 | context, | 403 | context, |
| 265 | - source.capturerObserver | 404 | + source.capturerObserver, |
| 266 | ) | 405 | ) |
| 267 | val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) | 406 | val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) |
| 268 | 407 | ||
| @@ -271,12 +410,12 @@ constructor( | @@ -271,12 +410,12 @@ constructor( | ||
| 271 | source = source, | 410 | source = source, |
| 272 | options = options, | 411 | options = options, |
| 273 | name = name, | 412 | name = name, |
| 274 | - rtcTrack = rtcTrack | 413 | + rtcTrack = rtcTrack, |
| 275 | ) | 414 | ) |
| 276 | 415 | ||
| 277 | track.closeableManager.registerResource( | 416 | track.closeableManager.registerResource( |
| 278 | rtcTrack, | 417 | rtcTrack, |
| 279 | - SurfaceTextureHelperCloser(surfaceTextureHelper) | 418 | + SurfaceTextureHelperCloser(surfaceTextureHelper), |
| 280 | ) | 419 | ) |
| 281 | return track | 420 | return track |
| 282 | } | 421 | } |
| @@ -303,7 +442,7 @@ constructor( | @@ -303,7 +442,7 @@ constructor( | ||
| 303 | capturer.initialize( | 442 | capturer.initialize( |
| 304 | surfaceTextureHelper, | 443 | surfaceTextureHelper, |
| 305 | context, | 444 | context, |
| 306 | - source.capturerObserver | 445 | + source.capturerObserver, |
| 307 | ) | 446 | ) |
| 308 | val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) | 447 | val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) |
| 309 | 448 | ||
| @@ -312,15 +451,22 @@ constructor( | @@ -312,15 +451,22 @@ constructor( | ||
| 312 | source = source, | 451 | source = source, |
| 313 | options = newOptions, | 452 | options = newOptions, |
| 314 | name = name, | 453 | name = name, |
| 315 | - rtcTrack = rtcTrack | 454 | + rtcTrack = rtcTrack, |
| 316 | ) | 455 | ) |
| 317 | 456 | ||
| 318 | track.closeableManager.registerResource( | 457 | track.closeableManager.registerResource( |
| 319 | rtcTrack, | 458 | rtcTrack, |
| 320 | - SurfaceTextureHelperCloser(surfaceTextureHelper) | 459 | + SurfaceTextureHelperCloser(surfaceTextureHelper), |
| 321 | ) | 460 | ) |
| 322 | 461 | ||
| 323 | return track | 462 | return track |
| 324 | } | 463 | } |
| 325 | } | 464 | } |
| 326 | } | 465 | } |
| 466 | + | ||
| 467 | +internal data class SimulcastTrackInfo( | ||
| 468 | + var codec: String, | ||
| 469 | + var rtcTrack: MediaStreamTrack, | ||
| 470 | + var sender: RtpSender? = null, | ||
| 471 | + var encodings: List<RtpParameters.Encoding>? = null, | ||
| 472 | +) |
| @@ -63,6 +63,14 @@ data class VideoEncoding( | @@ -63,6 +63,14 @@ data class VideoEncoding( | ||
| 63 | enum class VideoCodec(val codecName: String) { | 63 | enum class VideoCodec(val codecName: String) { |
| 64 | VP8("vp8"), | 64 | VP8("vp8"), |
| 65 | H264("h264"), | 65 | H264("h264"), |
| 66 | + VP9("vp9"), | ||
| 67 | + AV1("av1"); | ||
| 68 | + | ||
| 69 | + companion object { | ||
| 70 | + fun fromCodecName(codecName: String): VideoCodec { | ||
| 71 | + return VideoCodec.values().first { it.codecName.equals(codecName, ignoreCase = true) } | ||
| 72 | + } | ||
| 73 | + } | ||
| 66 | } | 74 | } |
| 67 | 75 | ||
| 68 | enum class CameraPosition { | 76 | enum class CameraPosition { |
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room.track.video | ||
| 18 | + | ||
| 19 | +data class ScalabilityMode(val spatial: Int, val temporal: Int, val suffix: String) { | ||
| 20 | + companion object { | ||
| 21 | + private val REGEX = """L(\d)T(\d)(h|_KEY|_KEY_SHIFT)?""".toRegex() | ||
| 22 | + fun parseFromString(mode: String): ScalabilityMode { | ||
| 23 | + val match = REGEX.matchEntire(mode) ?: throw IllegalArgumentException("can't parse scalability mode: $mode") | ||
| 24 | + val (spatial, temporal, suffix) = match.destructured | ||
| 25 | + | ||
| 26 | + return ScalabilityMode(spatial.toInt(), temporal.toInt(), suffix) | ||
| 27 | + } | ||
| 28 | + } | ||
| 29 | +} |
| @@ -72,7 +72,7 @@ open class CoroutineSdpObserver : SdpObserver { | @@ -72,7 +72,7 @@ open class CoroutineSdpObserver : SdpObserver { | ||
| 72 | setOutcome = Either.Right(message) | 72 | setOutcome = Either.Right(message) |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | - suspend fun awaitCreate() = suspendCoroutine<Either<SessionDescription, String?>> { cont -> | 75 | + suspend fun awaitCreate() = suspendCoroutine { cont -> |
| 76 | val curOutcome = createOutcome | 76 | val curOutcome = createOutcome |
| 77 | if (curOutcome != null) { | 77 | if (curOutcome != null) { |
| 78 | cont.resume(curOutcome) | 78 | cont.resume(curOutcome) |
| @@ -81,7 +81,7 @@ open class CoroutineSdpObserver : SdpObserver { | @@ -81,7 +81,7 @@ open class CoroutineSdpObserver : SdpObserver { | ||
| 81 | } | 81 | } |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | - suspend fun awaitSet() = suspendCoroutine<Either<Unit, String?>> { cont -> | 84 | + suspend fun awaitSet() = suspendCoroutine { cont -> |
| 85 | val curOutcome = setOutcome | 85 | val curOutcome = setOutcome |
| 86 | if (curOutcome != null) { | 86 | if (curOutcome != null) { |
| 87 | cont.resume(curOutcome) | 87 | cont.resume(curOutcome) |
| @@ -20,11 +20,15 @@ import io.livekit.android.room.track.VideoEncoding | @@ -20,11 +20,15 @@ import io.livekit.android.room.track.VideoEncoding | ||
| 20 | import io.livekit.android.room.track.VideoPreset | 20 | import io.livekit.android.room.track.VideoPreset |
| 21 | import io.livekit.android.room.track.VideoPreset169 | 21 | import io.livekit.android.room.track.VideoPreset169 |
| 22 | import io.livekit.android.room.track.VideoPreset43 | 22 | import io.livekit.android.room.track.VideoPreset43 |
| 23 | +import io.livekit.android.room.track.video.ScalabilityMode | ||
| 23 | import livekit.LivekitModels | 24 | import livekit.LivekitModels |
| 24 | import org.webrtc.RtpParameters | 25 | import org.webrtc.RtpParameters |
| 25 | import kotlin.math.abs | 26 | import kotlin.math.abs |
| 27 | +import kotlin.math.ceil | ||
| 26 | import kotlin.math.max | 28 | import kotlin.math.max |
| 27 | import kotlin.math.min | 29 | import kotlin.math.min |
| 30 | +import kotlin.math.pow | ||
| 31 | +import kotlin.math.roundToInt | ||
| 28 | 32 | ||
| 29 | internal object EncodingUtils { | 33 | internal object EncodingUtils { |
| 30 | 34 | ||
| @@ -83,6 +87,7 @@ internal object EncodingUtils { | @@ -83,6 +87,7 @@ internal object EncodingUtils { | ||
| 83 | trackWidth: Int, | 87 | trackWidth: Int, |
| 84 | trackHeight: Int, | 88 | trackHeight: Int, |
| 85 | encodings: List<RtpParameters.Encoding>, | 89 | encodings: List<RtpParameters.Encoding>, |
| 90 | + isSVC: Boolean, | ||
| 86 | ): List<LivekitModels.VideoLayer> { | 91 | ): List<LivekitModels.VideoLayer> { |
| 87 | return if (encodings.isEmpty()) { | 92 | return if (encodings.isEmpty()) { |
| 88 | listOf( | 93 | listOf( |
| @@ -94,6 +99,19 @@ internal object EncodingUtils { | @@ -94,6 +99,19 @@ internal object EncodingUtils { | ||
| 94 | ssrc = 0 | 99 | ssrc = 0 |
| 95 | }.build(), | 100 | }.build(), |
| 96 | ) | 101 | ) |
| 102 | + } else if (isSVC) { | ||
| 103 | + val encodingSM = encodings.first().scalabilityMode!! | ||
| 104 | + val scalabilityMode = ScalabilityMode.parseFromString(encodingSM) | ||
| 105 | + val maxBitrate = encodings.first().maxBitrateBps ?: 0 | ||
| 106 | + (0 until scalabilityMode.spatial).map { index -> | ||
| 107 | + LivekitModels.VideoLayer.newBuilder().apply { | ||
| 108 | + width = ceil(trackWidth / (2f.pow(index))).roundToInt() | ||
| 109 | + height = ceil(trackHeight / (2f.pow(index))).roundToInt() | ||
| 110 | + quality = LivekitModels.VideoQuality.forNumber(LivekitModels.VideoQuality.HIGH.number - index) | ||
| 111 | + bitrate = ceil(maxBitrate / 3f.pow(index)).roundToInt() | ||
| 112 | + ssrc = 0 | ||
| 113 | + }.build() | ||
| 114 | + } | ||
| 97 | } else { | 115 | } else { |
| 98 | encodings.map { encoding -> | 116 | encodings.map { encoding -> |
| 99 | val scaleDownBy = encoding.scaleResolutionDownBy ?: 1.0 | 117 | val scaleDownBy = encoding.scaleResolutionDownBy ?: 1.0 |
| @@ -17,6 +17,6 @@ | @@ -17,6 +17,6 @@ | ||
| 17 | package io.livekit.android.util | 17 | package io.livekit.android.util |
| 18 | 18 | ||
| 19 | sealed class Either<out A, out B> { | 19 | sealed class Either<out A, out B> { |
| 20 | - class Left<A>(val value: A) : Either<A, Nothing>() | ||
| 21 | - class Right<B>(val value: B) : Either<Nothing, B>() | 20 | + class Left<out A>(val value: A) : Either<A, Nothing>() |
| 21 | + class Right<out B>(val value: B) : Either<Nothing, B>() | ||
| 22 | } | 22 | } |
| @@ -23,7 +23,7 @@ import org.webrtc.VideoDecoder | @@ -23,7 +23,7 @@ import org.webrtc.VideoDecoder | ||
| 23 | import org.webrtc.VideoDecoderFactory | 23 | import org.webrtc.VideoDecoderFactory |
| 24 | import org.webrtc.WrappedVideoDecoderFactory | 24 | import org.webrtc.WrappedVideoDecoderFactory |
| 25 | 25 | ||
| 26 | -class CustomVideoDecoderFactory( | 26 | +open class CustomVideoDecoderFactory( |
| 27 | sharedContext: EglBase.Context?, | 27 | sharedContext: EglBase.Context?, |
| 28 | private var forceSWCodec: Boolean = false, | 28 | private var forceSWCodec: Boolean = false, |
| 29 | private var forceSWCodecs: List<String> = listOf("VP9"), | 29 | private var forceSWCodecs: List<String> = listOf("VP9"), |
| @@ -22,7 +22,7 @@ import org.webrtc.VideoCodecInfo | @@ -22,7 +22,7 @@ import org.webrtc.VideoCodecInfo | ||
| 22 | import org.webrtc.VideoEncoder | 22 | import org.webrtc.VideoEncoder |
| 23 | import org.webrtc.VideoEncoderFactory | 23 | import org.webrtc.VideoEncoderFactory |
| 24 | 24 | ||
| 25 | -class CustomVideoEncoderFactory( | 25 | +open class CustomVideoEncoderFactory( |
| 26 | sharedContext: EglBase.Context?, | 26 | sharedContext: EglBase.Context?, |
| 27 | enableIntelVp8Encoder: Boolean, | 27 | enableIntelVp8Encoder: Boolean, |
| 28 | enableH264HighProfile: Boolean, | 28 | enableH264HighProfile: Boolean, |
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.webrtc | ||
| 18 | + | ||
| 19 | +import android.gov.nist.javax.sdp.fields.AttributeField | ||
| 20 | +import android.javax.sdp.MediaDescription | ||
| 21 | +import io.livekit.android.util.LKLog | ||
| 22 | + | ||
| 23 | +data class SdpRtp(val payload: Long, val codec: String, val rate: Long?, val encoding: String?) | ||
| 24 | + | ||
| 25 | +fun MediaDescription.getRtps(): List<Pair<AttributeField, SdpRtp>> { | ||
| 26 | + return getAttributes(true) | ||
| 27 | + .filterIsInstance<AttributeField>() | ||
| 28 | + .filter { it.attribute.name == "rtpmap" } | ||
| 29 | + .mapNotNull { | ||
| 30 | + val rtp = tryParseRtp(it.value) | ||
| 31 | + if (rtp == null) { | ||
| 32 | + LKLog.w { "could not parse rtpmap: ${it.encode()}" } | ||
| 33 | + return@mapNotNull null | ||
| 34 | + } | ||
| 35 | + it to rtp | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +private val RTP = """(\d*) ([\w\-.]*)(?:\s*/(\d*)(?:\s*/(\S*))?)?""".toRegex() | ||
| 40 | +fun tryParseRtp(string: String): SdpRtp? { | ||
| 41 | + val match = RTP.matchEntire(string) ?: return null | ||
| 42 | + val (payload, codec, rate, encoding) = match.destructured | ||
| 43 | + return SdpRtp(payload.toLong(), codec, toOptionalLong(rate), toOptionalString(encoding)) | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +data class SdpMsid( | ||
| 47 | + /** holds the msid-id (and msid-appdata if available) */ | ||
| 48 | + val value: String, | ||
| 49 | +) | ||
| 50 | + | ||
| 51 | +fun MediaDescription.getMsid(): SdpMsid? { | ||
| 52 | + val attribute = getAttribute("msid") ?: return null | ||
| 53 | + return SdpMsid(attribute) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +data class SdpFmtp(val payload: Long, val config: String) { | ||
| 57 | + fun toAttributeField(): AttributeField { | ||
| 58 | + return AttributeField().apply { | ||
| 59 | + name = "fmtp" | ||
| 60 | + value = "$payload $config" | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +fun MediaDescription.getFmtps(): List<Pair<AttributeField, SdpFmtp>> { | ||
| 66 | + return getAttributes(true) | ||
| 67 | + .filterIsInstance<AttributeField>() | ||
| 68 | + .filter { it.attribute.name == "fmtp" } | ||
| 69 | + .mapNotNull { | ||
| 70 | + val fmtp = tryParseFmtp(it.value) | ||
| 71 | + if (fmtp == null) { | ||
| 72 | + LKLog.w { "could not parse fmtp: ${it.encode()}" } | ||
| 73 | + return@mapNotNull null | ||
| 74 | + } | ||
| 75 | + it to fmtp | ||
| 76 | + } | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +private val FMTP = """(\d*) ([\S| ]*)""".toRegex() | ||
| 80 | +fun tryParseFmtp(string: String): SdpFmtp? { | ||
| 81 | + val match = FMTP.matchEntire(string) ?: return null | ||
| 82 | + val (payload, config) = match.destructured | ||
| 83 | + return SdpFmtp(payload.toLong(), config) | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +data class SdpExt(val value: Long, val direction: String?, val encryptUri: String?, val uri: String, val config: String?) { | ||
| 87 | + fun toAttributeField(): AttributeField { | ||
| 88 | + return AttributeField().apply { | ||
| 89 | + name = "extmap" | ||
| 90 | + value = buildString { | ||
| 91 | + append(this@SdpExt.value) | ||
| 92 | + if (direction != null) { | ||
| 93 | + append(" $direction") | ||
| 94 | + } | ||
| 95 | + if (encryptUri != null) { | ||
| 96 | + append(" $encryptUri") | ||
| 97 | + } | ||
| 98 | + append(" $uri") | ||
| 99 | + if (config != null) { | ||
| 100 | + append(" $config") | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +fun MediaDescription.getExts(): List<Pair<AttributeField, SdpExt>> { | ||
| 108 | + return getAttributes(true) | ||
| 109 | + .filterIsInstance<AttributeField>() | ||
| 110 | + .filter { it.attribute.name == "extmap" } | ||
| 111 | + .mapNotNull { | ||
| 112 | + val ext = tryParseExt(it.value) | ||
| 113 | + if (ext == null) { | ||
| 114 | + LKLog.w { "could not parse extmap: ${it.encode()}" } | ||
| 115 | + return@mapNotNull null | ||
| 116 | + } | ||
| 117 | + it to ext | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +private val EXT = """(\d+)(?:/(\w+))?(?: (urn:ietf:params:rtp-hdrext:encrypt))? (\S*)(?: (\S*))?""".toRegex() | ||
| 122 | +fun tryParseExt(string: String): SdpExt? { | ||
| 123 | + val match = EXT.matchEntire(string) ?: return null | ||
| 124 | + val (value, direction, encryptUri, uri, config) = match.destructured | ||
| 125 | + return SdpExt(value.toLong(), toOptionalString(direction), toOptionalString(encryptUri), uri, toOptionalString(config)) | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +fun toOptionalLong(str: String): Long? = if (str.isEmpty()) null else str.toLong() | ||
| 129 | +fun toOptionalString(str: String): String? = str.ifEmpty { null } |
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.webrtc | ||
| 18 | + | ||
| 19 | +import io.livekit.android.dagger.CapabilitiesGetter | ||
| 20 | +import io.livekit.android.util.LKLog | ||
| 21 | +import org.webrtc.MediaStreamTrack | ||
| 22 | +import org.webrtc.RtpCapabilities | ||
| 23 | +import org.webrtc.RtpTransceiver | ||
| 24 | + | ||
| 25 | +internal fun RtpTransceiver.sortVideoCodecPreferences(targetCodec: String, capabilitiesGetter: CapabilitiesGetter) { | ||
| 26 | + val capabilities = capabilitiesGetter(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO) | ||
| 27 | + LKLog.v { "capabilities:" } | ||
| 28 | + capabilities.codecs.forEach { codec -> | ||
| 29 | + LKLog.v { "codec: ${codec.name}, ${codec.kind}, ${codec.mimeType}, ${codec.parameters}, ${codec.preferredPayloadType}" } | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + val matched = mutableListOf<RtpCapabilities.CodecCapability>() | ||
| 33 | + val partialMatched = mutableListOf<RtpCapabilities.CodecCapability>() | ||
| 34 | + val unmatched = mutableListOf<RtpCapabilities.CodecCapability>() | ||
| 35 | + | ||
| 36 | + for (codec in capabilities.codecs) { | ||
| 37 | + val mimeType = codec.mimeType.lowercase() | ||
| 38 | + if (mimeType == "audio/opus") { | ||
| 39 | + matched.add(codec) | ||
| 40 | + continue | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + if (mimeType != "video/$targetCodec") { | ||
| 44 | + unmatched.add(codec) | ||
| 45 | + continue | ||
| 46 | + } | ||
| 47 | + // for h264 codecs that have sdpFmtpLine available, use only if the | ||
| 48 | + // profile-level-id is 42e01f for cross-browser compatibility | ||
| 49 | + if (targetCodec == "h264") { | ||
| 50 | + if (codec.parameters["profile-level-id"] == "42e01f") { | ||
| 51 | + matched.add(codec) | ||
| 52 | + } else { | ||
| 53 | + partialMatched.add(codec) | ||
| 54 | + } | ||
| 55 | + continue | ||
| 56 | + } else { | ||
| 57 | + matched.add(codec) | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + setCodecPreferences(matched.plus(partialMatched).plus(unmatched)) | ||
| 61 | +} |
| @@ -18,9 +18,10 @@ package io.livekit.android.mock | @@ -18,9 +18,10 @@ package io.livekit.android.mock | ||
| 18 | 18 | ||
| 19 | import org.webrtc.VideoSink | 19 | import org.webrtc.VideoSink |
| 20 | import org.webrtc.VideoTrack | 20 | import org.webrtc.VideoTrack |
| 21 | +import java.util.UUID | ||
| 21 | 22 | ||
| 22 | class MockVideoStreamTrack( | 23 | class MockVideoStreamTrack( |
| 23 | - val id: String = "id", | 24 | + val id: String = UUID.randomUUID().toString(), |
| 24 | val kind: String = VIDEO_TRACK_KIND, | 25 | val kind: String = VIDEO_TRACK_KIND, |
| 25 | var enabled: Boolean = true, | 26 | var enabled: Boolean = true, |
| 26 | var state: State = State.LIVE, | 27 | var state: State = State.LIVE, |
| @@ -23,6 +23,7 @@ import livekit.LivekitRtc | @@ -23,6 +23,7 @@ import livekit.LivekitRtc | ||
| 23 | import okhttp3.Request | 23 | import okhttp3.Request |
| 24 | import okhttp3.WebSocket | 24 | import okhttp3.WebSocket |
| 25 | import okhttp3.WebSocketListener | 25 | import okhttp3.WebSocketListener |
| 26 | +import okio.ByteString | ||
| 26 | 27 | ||
| 27 | class MockWebSocketFactory : WebSocket.Factory { | 28 | class MockWebSocketFactory : WebSocket.Factory { |
| 28 | /** | 29 | /** |
| @@ -67,4 +68,8 @@ class MockWebSocketFactory : WebSocket.Factory { | @@ -67,4 +68,8 @@ class MockWebSocketFactory : WebSocket.Factory { | ||
| 67 | } | 68 | } |
| 68 | 69 | ||
| 69 | var onOpen: ((MockWebSocketFactory) -> Unit)? = null | 70 | var onOpen: ((MockWebSocketFactory) -> Unit)? = null |
| 71 | + | ||
| 72 | + fun receiveMessage(byteString: ByteString) { | ||
| 73 | + listener.onMessage(ws, byteString) | ||
| 74 | + } | ||
| 70 | } | 75 | } |
| @@ -17,6 +17,7 @@ | @@ -17,6 +17,7 @@ | ||
| 17 | package io.livekit.android.mock.dagger | 17 | package io.livekit.android.mock.dagger |
| 18 | 18 | ||
| 19 | import android.content.Context | 19 | import android.content.Context |
| 20 | +import android.javax.sdp.SdpFactory | ||
| 20 | import dagger.Module | 21 | import dagger.Module |
| 21 | import dagger.Provides | 22 | import dagger.Provides |
| 22 | import io.livekit.android.dagger.CapabilitiesGetter | 23 | import io.livekit.android.dagger.CapabilitiesGetter |
| @@ -59,4 +60,7 @@ object TestRTCModule { | @@ -59,4 +60,7 @@ object TestRTCModule { | ||
| 59 | @Provides | 60 | @Provides |
| 60 | @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) | 61 | @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) |
| 61 | fun videoHwAccel() = true | 62 | fun videoHwAccel() = true |
| 63 | + | ||
| 64 | + @Provides | ||
| 65 | + fun sdpFactory() = SdpFactory.getInstance() | ||
| 62 | } | 66 | } |
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room | ||
| 18 | + | ||
| 19 | +import android.javax.sdp.MediaDescription | ||
| 20 | +import android.javax.sdp.SdpFactory | ||
| 21 | +import io.livekit.android.webrtc.JainSdpUtilsTest | ||
| 22 | +import io.livekit.android.webrtc.getExts | ||
| 23 | +import io.livekit.android.webrtc.getFmtps | ||
| 24 | +import org.junit.Assert.assertEquals | ||
| 25 | +import org.junit.Assert.assertNotNull | ||
| 26 | +import org.junit.Assert.assertNull | ||
| 27 | +import org.junit.Test | ||
| 28 | + | ||
| 29 | +class SdpMungingTest { | ||
| 30 | + | ||
| 31 | + @Test | ||
| 32 | + fun ensureVideoDDExtensionForSVCTest() { | ||
| 33 | + val sdp = SdpFactory.getInstance().createSessionDescription(NO_DD_DESCRIPTION) | ||
| 34 | + val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance<MediaDescription>()[1] | ||
| 35 | + | ||
| 36 | + ensureVideoDDExtensionForSVC(mediaDescription) | ||
| 37 | + | ||
| 38 | + val exts = mediaDescription.getExts() | ||
| 39 | + | ||
| 40 | + assertEquals(12, exts.size) | ||
| 41 | + | ||
| 42 | + val ddExtPair = exts.find { it.second.value == 12L } | ||
| 43 | + | ||
| 44 | + assertNotNull(ddExtPair) | ||
| 45 | + val (_, ext) = ddExtPair!! | ||
| 46 | + | ||
| 47 | + assertEquals(12, ext.value) | ||
| 48 | + assertNull(ext.direction) | ||
| 49 | + assertNull(ext.encryptUri) | ||
| 50 | + assertEquals("https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension", ext.uri) | ||
| 51 | + assertNull(ext.config) | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + fun ensureCodecBitratesTest() { | ||
| 56 | + val sdp = SdpFactory.getInstance().createSessionDescription(JainSdpUtilsTest.DESCRIPTION) | ||
| 57 | + val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance<MediaDescription>()[1] | ||
| 58 | + | ||
| 59 | + ensureCodecBitrates( | ||
| 60 | + mediaDescription, | ||
| 61 | + mapOf( | ||
| 62 | + TrackBitrateInfoKey.Cid("PA_Qwqk4y9fcD3G") to | ||
| 63 | + TrackBitrateInfo( | ||
| 64 | + "VP9", | ||
| 65 | + 1000000L, | ||
| 66 | + ), | ||
| 67 | + ), | ||
| 68 | + ) | ||
| 69 | + | ||
| 70 | + val (_, vp9fmtp) = mediaDescription.getFmtps() | ||
| 71 | + .filter { (_, fmtp) -> fmtp.payload == 98L } | ||
| 72 | + .first() | ||
| 73 | + | ||
| 74 | + assertEquals("profile-id=0;x-google-start-bitrate=700000;x-google-max-bitrate=1000000", vp9fmtp.config) | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + companion object { | ||
| 78 | + const val NO_DD_DESCRIPTION = "v=0\n" + | ||
| 79 | + "o=- 3682890773448528616 3 IN IP4 127.0.0.1\n" + | ||
| 80 | + "s=-\n" + | ||
| 81 | + "t=0 0\n" + | ||
| 82 | + "a=group:BUNDLE 0 1\n" + | ||
| 83 | + "a=extmap-allow-mixed\n" + | ||
| 84 | + "a=msid-semantic: WMS PA_Qwqk4y9fcD3G\n" + | ||
| 85 | + "m=application 24436 UDP/DTLS/SCTP webrtc-datachannel\n" + | ||
| 86 | + "c=IN IP4 45.76.222.83\n" + | ||
| 87 | + "a=candidate:1660983843 1 udp 2122194687 192.168.0.22 37324 typ host generation 0 network-id 5 network-cost 10\n" + | ||
| 88 | + "a=candidate:901011812 1 udp 2122262783 2400:4050:28c2:200:9ce6:9cff:fe22:a74d 48779 typ host generation 0 network-id 6 network-cost 10\n" + | ||
| 89 | + "a=candidate:3061937641 1 udp 1685987071 123.222.220.24 37324 typ srflx raddr 192.168.0.22 rport 37324 generation 0 network-id 5 network-cost 10\n" + | ||
| 90 | + "a=candidate:689824452 1 tcp 1518083839 10.244.232.137 9 typ host tcptype active generation 0 network-id 3 network-cost 900\n" + | ||
| 91 | + "a=candidate:2149072150 1 tcp 1518151935 2407:5300:1029:7a1f:96b6:6f1e:ec66:64d8 9 typ host tcptype active generation 0 network-id 4 network-cost 900\n" + | ||
| 92 | + "a=candidate:3396018872 1 udp 41820671 45.76.222.83 24436 typ relay raddr 123.222.220.24 rport 37324 generation 0 network-id 5 network-cost 10\n" + | ||
| 93 | + "a=ice-ufrag:+2SN\n" + | ||
| 94 | + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" + | ||
| 95 | + "a=ice-options:trickle renomination\n" + | ||
| 96 | + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" + | ||
| 97 | + "a=setup:actpass\n" + | ||
| 98 | + "a=mid:0\n" + | ||
| 99 | + "a=sctp-port:5000\n" + | ||
| 100 | + "a=max-message-size:262144\n" + | ||
| 101 | + "m=video 9 UDP/TLS/RTP/SAVPF 96 97 127 103 104 105 39 40 98 99 106 107 108\n" + | ||
| 102 | + "c=IN IP4 0.0.0.0\n" + | ||
| 103 | + "a=rtcp:9 IN IP4 0.0.0.0\n" + | ||
| 104 | + "a=ice-ufrag:+2SN\n" + | ||
| 105 | + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" + | ||
| 106 | + "a=ice-options:trickle renomination\n" + | ||
| 107 | + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" + | ||
| 108 | + "a=setup:actpass\n" + | ||
| 109 | + "a=mid:1\n" + | ||
| 110 | + "a=extmap:1 urn:ietf:params:rtp-hdrext:toffset\n" + | ||
| 111 | + "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n" + | ||
| 112 | + "a=extmap:3 urn:3gpp:video-orientation\n" + | ||
| 113 | + "a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" + | ||
| 114 | + "a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n" + | ||
| 115 | + "a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n" + | ||
| 116 | + "a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n" + | ||
| 117 | + "a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n" + | ||
| 118 | + "a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\n" + | ||
| 119 | + "a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" + | ||
| 120 | + "a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" + | ||
| 121 | + "a=sendonly\n" + | ||
| 122 | + "a=msid:PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c\n" + | ||
| 123 | + "a=rtcp-mux\n" + | ||
| 124 | + "a=rtcp-rsize\n" + | ||
| 125 | + "a=rtpmap:98 VP9/90000\n" + | ||
| 126 | + "a=rtcp-fb:98 goog-remb\n" + | ||
| 127 | + "a=rtcp-fb:98 transport-cc\n" + | ||
| 128 | + "a=rtcp-fb:98 ccm fir\n" + | ||
| 129 | + "a=rtcp-fb:98 nack\n" + | ||
| 130 | + "a=rtcp-fb:98 nack pli\n" + | ||
| 131 | + "a=fmtp:98 profile-id=0\n" + | ||
| 132 | + "a=rtpmap:96 VP8/90000\n" + | ||
| 133 | + "a=rtcp-fb:96 goog-remb\n" + | ||
| 134 | + "a=rtcp-fb:96 transport-cc\n" + | ||
| 135 | + "a=rtcp-fb:96 ccm fir\n" + | ||
| 136 | + "a=rtcp-fb:96 nack\n" + | ||
| 137 | + "a=rtcp-fb:96 nack pli\n" + | ||
| 138 | + "a=rtpmap:97 rtx/90000\n" + | ||
| 139 | + "a=fmtp:97 apt=96\n" + | ||
| 140 | + "a=rtpmap:127 H264/90000\n" + | ||
| 141 | + "a=rtcp-fb:127 goog-remb\n" + | ||
| 142 | + "a=rtcp-fb:127 transport-cc\n" + | ||
| 143 | + "a=rtcp-fb:127 ccm fir\n" + | ||
| 144 | + "a=rtcp-fb:127 nack\n" + | ||
| 145 | + "a=rtcp-fb:127 nack pli\n" + | ||
| 146 | + "a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" + | ||
| 147 | + "a=rtpmap:103 rtx/90000\n" + | ||
| 148 | + "a=fmtp:103 apt=127\n" + | ||
| 149 | + "a=rtpmap:104 H265/90000\n" + | ||
| 150 | + "a=rtcp-fb:104 goog-remb\n" + | ||
| 151 | + "a=rtcp-fb:104 transport-cc\n" + | ||
| 152 | + "a=rtcp-fb:104 ccm fir\n" + | ||
| 153 | + "a=rtcp-fb:104 nack\n" + | ||
| 154 | + "a=rtcp-fb:104 nack pli\n" + | ||
| 155 | + "a=rtpmap:105 rtx/90000\n" + | ||
| 156 | + "a=fmtp:105 apt=104\n" + | ||
| 157 | + "a=rtpmap:39 AV1/90000\n" + | ||
| 158 | + "a=rtcp-fb:39 goog-remb\n" + | ||
| 159 | + "a=rtcp-fb:39 transport-cc\n" + | ||
| 160 | + "a=rtcp-fb:39 ccm fir\n" + | ||
| 161 | + "a=rtcp-fb:39 nack\n" + | ||
| 162 | + "a=rtcp-fb:39 nack pli\n" + | ||
| 163 | + "a=rtpmap:40 rtx/90000\n" + | ||
| 164 | + "a=fmtp:40 apt=39\n" + | ||
| 165 | + "a=rtpmap:99 rtx/90000\n" + | ||
| 166 | + "a=fmtp:99 apt=98\n" + | ||
| 167 | + "a=rtpmap:106 red/90000\n" + | ||
| 168 | + "a=rtpmap:107 rtx/90000\n" + | ||
| 169 | + "a=fmtp:107 apt=106\n" + | ||
| 170 | + "a=rtpmap:108 ulpfec/90000\n" + | ||
| 171 | + "a=rid:h send\n" + | ||
| 172 | + "a=rid:q send\n" + | ||
| 173 | + "a=simulcast:send h;q" | ||
| 174 | + } | ||
| 175 | +} |
livekit-android-sdk/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt
| @@ -23,6 +23,7 @@ import io.livekit.android.events.ParticipantEvent | @@ -23,6 +23,7 @@ import io.livekit.android.events.ParticipantEvent | ||
| 23 | import io.livekit.android.events.RoomEvent | 23 | import io.livekit.android.events.RoomEvent |
| 24 | import io.livekit.android.mock.MockAudioStreamTrack | 24 | import io.livekit.android.mock.MockAudioStreamTrack |
| 25 | import io.livekit.android.mock.MockEglBase | 25 | import io.livekit.android.mock.MockEglBase |
| 26 | +import io.livekit.android.mock.MockPeerConnection | ||
| 26 | import io.livekit.android.mock.MockVideoCapturer | 27 | import io.livekit.android.mock.MockVideoCapturer |
| 27 | import io.livekit.android.mock.MockVideoStreamTrack | 28 | import io.livekit.android.mock.MockVideoStreamTrack |
| 28 | import io.livekit.android.room.DefaultsManager | 29 | import io.livekit.android.room.DefaultsManager |
| @@ -31,10 +32,14 @@ import io.livekit.android.room.track.LocalAudioTrack | @@ -31,10 +32,14 @@ import io.livekit.android.room.track.LocalAudioTrack | ||
| 31 | import io.livekit.android.room.track.LocalVideoTrack | 32 | import io.livekit.android.room.track.LocalVideoTrack |
| 32 | import io.livekit.android.room.track.LocalVideoTrackOptions | 33 | import io.livekit.android.room.track.LocalVideoTrackOptions |
| 33 | import io.livekit.android.room.track.VideoCaptureParameter | 34 | import io.livekit.android.room.track.VideoCaptureParameter |
| 35 | +import io.livekit.android.room.track.VideoCodec | ||
| 34 | import io.livekit.android.util.toOkioByteString | 36 | import io.livekit.android.util.toOkioByteString |
| 35 | import io.livekit.android.util.toPBByteString | 37 | import io.livekit.android.util.toPBByteString |
| 36 | import kotlinx.coroutines.ExperimentalCoroutinesApi | 38 | import kotlinx.coroutines.ExperimentalCoroutinesApi |
| 39 | +import livekit.LivekitModels | ||
| 37 | import livekit.LivekitRtc | 40 | import livekit.LivekitRtc |
| 41 | +import livekit.LivekitRtc.SubscribedCodec | ||
| 42 | +import livekit.LivekitRtc.SubscribedQuality | ||
| 38 | import org.junit.Assert.* | 43 | import org.junit.Assert.* |
| 39 | import org.junit.Test | 44 | import org.junit.Test |
| 40 | import org.junit.runner.RunWith | 45 | import org.junit.runner.RunWith |
| @@ -200,4 +205,108 @@ class LocalParticipantMockE2ETest : MockE2ETest() { | @@ -200,4 +205,108 @@ class LocalParticipantMockE2ETest : MockE2ETest() { | ||
| 200 | }, | 205 | }, |
| 201 | ) | 206 | ) |
| 202 | } | 207 | } |
| 208 | + | ||
| 209 | + @Test | ||
| 210 | + fun publishSvcCodec() = runTest { | ||
| 211 | + room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy( | ||
| 212 | + videoCodec = VideoCodec.VP9.codecName, | ||
| 213 | + scalabilityMode = "L3T3", | ||
| 214 | + backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName), | ||
| 215 | + ) | ||
| 216 | + | ||
| 217 | + connect() | ||
| 218 | + wsFactory.ws.clearRequests() | ||
| 219 | + room.localParticipant.publishVideoTrack(track = createLocalTrack()) | ||
| 220 | + | ||
| 221 | + // Expect add track request to contain both primary and backup | ||
| 222 | + assertEquals(1, wsFactory.ws.sentRequests.size) | ||
| 223 | + | ||
| 224 | + val requestString = wsFactory.ws.sentRequests[0] | ||
| 225 | + val signalRequest = LivekitRtc.SignalRequest.newBuilder() | ||
| 226 | + .mergeFrom(requestString.toPBByteString()) | ||
| 227 | + .build() | ||
| 228 | + assertTrue(signalRequest.hasAddTrack()) | ||
| 229 | + | ||
| 230 | + val addTrackRequest = signalRequest.addTrack | ||
| 231 | + assertEquals(2, addTrackRequest.simulcastCodecsList.size) | ||
| 232 | + | ||
| 233 | + val vp9Codec = addTrackRequest.simulcastCodecsList[0] | ||
| 234 | + assertEquals("vp9", vp9Codec.codec) | ||
| 235 | + | ||
| 236 | + val vp8Codec = addTrackRequest.simulcastCodecsList[1] | ||
| 237 | + assertEquals("vp8", vp8Codec.codec) | ||
| 238 | + | ||
| 239 | + val publisherConn = component.rtcEngine().publisher.peerConnection as MockPeerConnection | ||
| 240 | + | ||
| 241 | + assertEquals(1, publisherConn.transceivers.size) | ||
| 242 | + Mockito.verify(publisherConn.transceivers.first()).setCodecPreferences( | ||
| 243 | + argThat { codecs -> | ||
| 244 | + val preferredCodec = codecs.first() | ||
| 245 | + return@argThat preferredCodec.name.lowercase() == "vp9" | ||
| 246 | + }, | ||
| 247 | + ) | ||
| 248 | + | ||
| 249 | + // Ensure the newly subscribed vp8 codec gets added as a new transceiver. | ||
| 250 | + wsFactory.receiveMessage( | ||
| 251 | + with(LivekitRtc.SignalResponse.newBuilder()) { | ||
| 252 | + subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) { | ||
| 253 | + trackSid = room.localParticipant.videoTracks.first().first.sid | ||
| 254 | + addAllSubscribedCodecs( | ||
| 255 | + listOf( | ||
| 256 | + with(SubscribedCodec.newBuilder()) { | ||
| 257 | + codec = "vp9" | ||
| 258 | + addAllQualities( | ||
| 259 | + listOf( | ||
| 260 | + SubscribedQuality.newBuilder() | ||
| 261 | + .setQuality(LivekitModels.VideoQuality.HIGH) | ||
| 262 | + .setEnabled(true) | ||
| 263 | + .build(), | ||
| 264 | + SubscribedQuality.newBuilder() | ||
| 265 | + .setQuality(LivekitModels.VideoQuality.MEDIUM) | ||
| 266 | + .setEnabled(true) | ||
| 267 | + .build(), | ||
| 268 | + SubscribedQuality.newBuilder() | ||
| 269 | + .setQuality(LivekitModels.VideoQuality.LOW) | ||
| 270 | + .setEnabled(true) | ||
| 271 | + .build(), | ||
| 272 | + ), | ||
| 273 | + ) | ||
| 274 | + build() | ||
| 275 | + }, | ||
| 276 | + with(SubscribedCodec.newBuilder()) { | ||
| 277 | + codec = "vp8" | ||
| 278 | + addAllQualities( | ||
| 279 | + listOf( | ||
| 280 | + SubscribedQuality.newBuilder() | ||
| 281 | + .setQuality(LivekitModels.VideoQuality.HIGH) | ||
| 282 | + .setEnabled(true) | ||
| 283 | + .build(), | ||
| 284 | + SubscribedQuality.newBuilder() | ||
| 285 | + .setQuality(LivekitModels.VideoQuality.MEDIUM) | ||
| 286 | + .setEnabled(true) | ||
| 287 | + .build(), | ||
| 288 | + SubscribedQuality.newBuilder() | ||
| 289 | + .setQuality(LivekitModels.VideoQuality.LOW) | ||
| 290 | + .setEnabled(true) | ||
| 291 | + .build(), | ||
| 292 | + ), | ||
| 293 | + ) | ||
| 294 | + build() | ||
| 295 | + }, | ||
| 296 | + ), | ||
| 297 | + ) | ||
| 298 | + build() | ||
| 299 | + } | ||
| 300 | + build().toOkioByteString() | ||
| 301 | + }, | ||
| 302 | + ) | ||
| 303 | + | ||
| 304 | + assertEquals(2, publisherConn.transceivers.size) | ||
| 305 | + Mockito.verify(publisherConn.transceivers.last()).setCodecPreferences( | ||
| 306 | + argThat { codecs -> | ||
| 307 | + val preferredCodec = codecs.first() | ||
| 308 | + return@argThat preferredCodec.name.lowercase() == "vp8" | ||
| 309 | + }, | ||
| 310 | + ) | ||
| 311 | + } | ||
| 203 | } | 312 | } |
livekit-android-sdk/src/test/java/io/livekit/android/room/track/video/ScalabilityModeTest.kt
0 → 100644
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.room.track.video | ||
| 18 | + | ||
| 19 | +import org.junit.Assert.assertEquals | ||
| 20 | +import org.junit.Test | ||
| 21 | + | ||
| 22 | +class ScalabilityModeTest { | ||
| 23 | + | ||
| 24 | + @Test | ||
| 25 | + fun testL1T3() { | ||
| 26 | + val mode = ScalabilityMode.parseFromString("L1T3") | ||
| 27 | + | ||
| 28 | + assertEquals(1, mode.spatial) | ||
| 29 | + assertEquals(3, mode.temporal) | ||
| 30 | + assertEquals("", mode.suffix) | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + @Test | ||
| 34 | + fun testL3T3_KEY() { | ||
| 35 | + val mode = ScalabilityMode.parseFromString("L3T3_KEY") | ||
| 36 | + | ||
| 37 | + assertEquals(3, mode.spatial) | ||
| 38 | + assertEquals(3, mode.temporal) | ||
| 39 | + assertEquals("_KEY", mode.suffix) | ||
| 40 | + } | ||
| 41 | +} |
| 1 | +/* | ||
| 2 | + * Copyright 2023 LiveKit, Inc. | ||
| 3 | + * | ||
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | + * you may not use this file except in compliance with the License. | ||
| 6 | + * You may obtain a copy of the License at | ||
| 7 | + * | ||
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | + * | ||
| 10 | + * Unless required by applicable law or agreed to in writing, software | ||
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | + * See the License for the specific language governing permissions and | ||
| 14 | + * limitations under the License. | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +package io.livekit.android.webrtc | ||
| 18 | + | ||
| 19 | +import android.javax.sdp.MediaDescription | ||
| 20 | +import android.javax.sdp.SdpFactory | ||
| 21 | +import android.javax.sdp.SessionDescription | ||
| 22 | +import org.junit.Assert.assertEquals | ||
| 23 | +import org.junit.Assert.assertNotNull | ||
| 24 | +import org.junit.Assert.assertNull | ||
| 25 | +import org.junit.Test | ||
| 26 | + | ||
| 27 | +class JainSdpUtilsTest { | ||
| 28 | + | ||
| 29 | + private val sdpFactory = SdpFactory.getInstance() | ||
| 30 | + private fun createSessionDescription(): SessionDescription { | ||
| 31 | + return sdpFactory.createSessionDescription(DESCRIPTION) | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @Test | ||
| 35 | + fun getRtpAttributes() { | ||
| 36 | + val sdp = createSessionDescription() | ||
| 37 | + val mediaDescriptions = sdp.getMediaDescriptions(true) | ||
| 38 | + .filterIsInstance<MediaDescription>() | ||
| 39 | + val mediaDesc = mediaDescriptions[1] | ||
| 40 | + val rtps = mediaDesc.getRtps() | ||
| 41 | + assertEquals(13, rtps.size) | ||
| 42 | + | ||
| 43 | + val (_, vp8Rtp) = rtps[0] | ||
| 44 | + | ||
| 45 | + assertEquals(96, vp8Rtp.payload) | ||
| 46 | + assertEquals("VP8", vp8Rtp.codec) | ||
| 47 | + assertEquals(90000L, vp8Rtp.rate) | ||
| 48 | + assertNull(vp8Rtp.encoding) | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + @Test | ||
| 52 | + fun getExtmapAttributes() { | ||
| 53 | + val sdp = createSessionDescription() | ||
| 54 | + val mediaDescriptions = sdp.getMediaDescriptions(true) | ||
| 55 | + .filterIsInstance<MediaDescription>() | ||
| 56 | + val mediaDesc = mediaDescriptions[1] | ||
| 57 | + val exts = mediaDesc.getExts() | ||
| 58 | + | ||
| 59 | + assertEquals(12, exts.size) | ||
| 60 | + | ||
| 61 | + val (_, ext) = exts[0] | ||
| 62 | + assertEquals(1, ext.value) | ||
| 63 | + assertNull(ext.direction) | ||
| 64 | + assertNull(ext.encryptUri) | ||
| 65 | + assertEquals("urn:ietf:params:rtp-hdrext:toffset", ext.uri) | ||
| 66 | + assertNull(ext.config) | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + @Test | ||
| 70 | + fun getMsid() { | ||
| 71 | + val sdp = createSessionDescription() | ||
| 72 | + val mediaDescriptions = sdp.getMediaDescriptions(true) | ||
| 73 | + .filterIsInstance<MediaDescription>() | ||
| 74 | + val mediaDesc = mediaDescriptions[1] | ||
| 75 | + | ||
| 76 | + val msid = mediaDesc.getMsid() | ||
| 77 | + assertNotNull(msid) | ||
| 78 | + assertEquals("PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c", msid!!.value) | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + @Test | ||
| 82 | + fun getFmtps() { | ||
| 83 | + val sdp = createSessionDescription() | ||
| 84 | + val mediaDescriptions = sdp.getMediaDescriptions(true) | ||
| 85 | + .filterIsInstance<MediaDescription>() | ||
| 86 | + val mediaDesc = mediaDescriptions[1] | ||
| 87 | + | ||
| 88 | + val fmtps = mediaDesc.getFmtps() | ||
| 89 | + .filter { (_, fmtp) -> fmtp.payload == 97L } | ||
| 90 | + assertEquals(1, fmtps.size) | ||
| 91 | + | ||
| 92 | + val (_, fmtp) = fmtps[0] | ||
| 93 | + assertEquals("apt=96", fmtp.config) | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + companion object { | ||
| 97 | + const val DESCRIPTION = "v=0\n" + | ||
| 98 | + "o=- 3682890773448528616 3 IN IP4 127.0.0.1\n" + | ||
| 99 | + "s=-\n" + | ||
| 100 | + "t=0 0\n" + | ||
| 101 | + "a=group:BUNDLE 0 1\n" + | ||
| 102 | + "a=extmap-allow-mixed\n" + | ||
| 103 | + "a=msid-semantic: WMS PA_Qwqk4y9fcD3G\n" + | ||
| 104 | + "m=application 24436 UDP/DTLS/SCTP webrtc-datachannel\n" + | ||
| 105 | + "c=IN IP4 45.76.222.83\n" + | ||
| 106 | + "a=candidate:1660983843 1 udp 2122194687 192.168.0.22 37324 typ host generation 0 network-id 5 network-cost 10\n" + | ||
| 107 | + "a=candidate:901011812 1 udp 2122262783 2400:4050:28c2:200:9ce6:9cff:fe22:a74d 48779 typ host generation 0 network-id 6 network-cost 10\n" + | ||
| 108 | + "a=candidate:3061937641 1 udp 1685987071 123.222.220.24 37324 typ srflx raddr 192.168.0.22 rport 37324 generation 0 network-id 5 network-cost 10\n" + | ||
| 109 | + "a=candidate:689824452 1 tcp 1518083839 10.244.232.137 9 typ host tcptype active generation 0 network-id 3 network-cost 900\n" + | ||
| 110 | + "a=candidate:2149072150 1 tcp 1518151935 2407:5300:1029:7a1f:96b6:6f1e:ec66:64d8 9 typ host tcptype active generation 0 network-id 4 network-cost 900\n" + | ||
| 111 | + "a=candidate:3396018872 1 udp 41820671 45.76.222.83 24436 typ relay raddr 123.222.220.24 rport 37324 generation 0 network-id 5 network-cost 10\n" + | ||
| 112 | + "a=ice-ufrag:+2SN\n" + | ||
| 113 | + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" + | ||
| 114 | + "a=ice-options:trickle renomination\n" + | ||
| 115 | + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" + | ||
| 116 | + "a=setup:actpass\n" + | ||
| 117 | + "a=mid:0\n" + | ||
| 118 | + "a=sctp-port:5000\n" + | ||
| 119 | + "a=max-message-size:262144\n" + | ||
| 120 | + "m=video 9 UDP/TLS/RTP/SAVPF 96 97 127 103 104 105 39 40 98 99 106 107 108\n" + | ||
| 121 | + "c=IN IP4 0.0.0.0\n" + | ||
| 122 | + "a=rtcp:9 IN IP4 0.0.0.0\n" + | ||
| 123 | + "a=ice-ufrag:+2SN\n" + | ||
| 124 | + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" + | ||
| 125 | + "a=ice-options:trickle renomination\n" + | ||
| 126 | + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" + | ||
| 127 | + "a=setup:actpass\n" + | ||
| 128 | + "a=mid:1\n" + | ||
| 129 | + "a=extmap:1 urn:ietf:params:rtp-hdrext:toffset\n" + | ||
| 130 | + "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n" + | ||
| 131 | + "a=extmap:3 urn:3gpp:video-orientation\n" + | ||
| 132 | + "a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" + | ||
| 133 | + "a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n" + | ||
| 134 | + "a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n" + | ||
| 135 | + "a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n" + | ||
| 136 | + "a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n" + | ||
| 137 | + "a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\n" + | ||
| 138 | + "a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" + | ||
| 139 | + "a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" + | ||
| 140 | + "a=extmap:12 https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension\n" + | ||
| 141 | + "a=sendonly\n" + | ||
| 142 | + "a=msid:PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c\n" + | ||
| 143 | + "a=rtcp-mux\n" + | ||
| 144 | + "a=rtcp-rsize\n" + | ||
| 145 | + "a=rtpmap:96 VP8/90000\n" + | ||
| 146 | + "a=rtcp-fb:96 goog-remb\n" + | ||
| 147 | + "a=rtcp-fb:96 transport-cc\n" + | ||
| 148 | + "a=rtcp-fb:96 ccm fir\n" + | ||
| 149 | + "a=rtcp-fb:96 nack\n" + | ||
| 150 | + "a=rtcp-fb:96 nack pli\n" + | ||
| 151 | + "a=rtpmap:97 rtx/90000\n" + | ||
| 152 | + "a=fmtp:97 apt=96\n" + | ||
| 153 | + "a=rtpmap:127 H264/90000\n" + | ||
| 154 | + "a=rtcp-fb:127 goog-remb\n" + | ||
| 155 | + "a=rtcp-fb:127 transport-cc\n" + | ||
| 156 | + "a=rtcp-fb:127 ccm fir\n" + | ||
| 157 | + "a=rtcp-fb:127 nack\n" + | ||
| 158 | + "a=rtcp-fb:127 nack pli\n" + | ||
| 159 | + "a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" + | ||
| 160 | + "a=rtpmap:103 rtx/90000\n" + | ||
| 161 | + "a=fmtp:103 apt=127\n" + | ||
| 162 | + "a=rtpmap:104 H265/90000\n" + | ||
| 163 | + "a=rtcp-fb:104 goog-remb\n" + | ||
| 164 | + "a=rtcp-fb:104 transport-cc\n" + | ||
| 165 | + "a=rtcp-fb:104 ccm fir\n" + | ||
| 166 | + "a=rtcp-fb:104 nack\n" + | ||
| 167 | + "a=rtcp-fb:104 nack pli\n" + | ||
| 168 | + "a=rtpmap:105 rtx/90000\n" + | ||
| 169 | + "a=fmtp:105 apt=104\n" + | ||
| 170 | + "a=rtpmap:39 AV1/90000\n" + | ||
| 171 | + "a=rtcp-fb:39 goog-remb\n" + | ||
| 172 | + "a=rtcp-fb:39 transport-cc\n" + | ||
| 173 | + "a=rtcp-fb:39 ccm fir\n" + | ||
| 174 | + "a=rtcp-fb:39 nack\n" + | ||
| 175 | + "a=rtcp-fb:39 nack pli\n" + | ||
| 176 | + "a=rtpmap:40 rtx/90000\n" + | ||
| 177 | + "a=fmtp:40 apt=39\n" + | ||
| 178 | + "a=rtpmap:98 VP9/90000\n" + | ||
| 179 | + "a=rtcp-fb:98 goog-remb\n" + | ||
| 180 | + "a=rtcp-fb:98 transport-cc\n" + | ||
| 181 | + "a=rtcp-fb:98 ccm fir\n" + | ||
| 182 | + "a=rtcp-fb:98 nack\n" + | ||
| 183 | + "a=rtcp-fb:98 nack pli\n" + | ||
| 184 | + "a=fmtp:98 profile-id=0\n" + | ||
| 185 | + "a=rtpmap:99 rtx/90000\n" + | ||
| 186 | + "a=fmtp:99 apt=98\n" + | ||
| 187 | + "a=rtpmap:106 red/90000\n" + | ||
| 188 | + "a=rtpmap:107 rtx/90000\n" + | ||
| 189 | + "a=fmtp:107 apt=106\n" + | ||
| 190 | + "a=rtpmap:108 ulpfec/90000\n" + | ||
| 191 | + "a=rid:h send\n" + | ||
| 192 | + "a=rid:q send\n" + | ||
| 193 | + "a=simulcast:send h;q" | ||
| 194 | + } | ||
| 195 | +} |
| @@ -63,6 +63,18 @@ class MockPeerConnectionFactory : PeerConnectionFactory(1L) { | @@ -63,6 +63,18 @@ class MockPeerConnectionFactory : PeerConnectionFactory(1L) { | ||
| 63 | kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO | 63 | kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO |
| 64 | parameters = mapOf("profile-level-id" to "42e01f") | 64 | parameters = mapOf("profile-level-id" to "42e01f") |
| 65 | }, | 65 | }, |
| 66 | + RtpCapabilities.CodecCapability().apply { | ||
| 67 | + name = "AV1" | ||
| 68 | + mimeType = "video/AV1" | ||
| 69 | + kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO | ||
| 70 | + parameters = emptyMap() | ||
| 71 | + }, | ||
| 72 | + RtpCapabilities.CodecCapability().apply { | ||
| 73 | + name = "VP9" | ||
| 74 | + mimeType = "video/VP9" | ||
| 75 | + kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO | ||
| 76 | + parameters = mapOf("profile-id" to "0") | ||
| 77 | + }, | ||
| 66 | ), | 78 | ), |
| 67 | emptyList(), | 79 | emptyList(), |
| 68 | ) | 80 | ) |
-
请 注册 或 登录 后发表评论