iOS应用上的WebRTC


自我介绍

iOS应用工程师
感觉像我们在做iOS应用程序,Android应用程序和Web(java / ruby?? / php)一样,具有更广泛的支持。

动机

  • 决定在企业中使用WebRTC实施视频通话。
  • 但是,因为它使用了Twilio Voideo SDK(包括服务器的视频通话服务),所以即使我不太了解WebRTC,也可以按照示例和入门来实现它。
  • 我想知道WebRTC本身是如何组成的。

什么是WebRTC?

维基百科的

  • 这是一个免费的开源项目,可通过简单的API与Web浏览器和移动应用程序提供实时通信(RTC)。网页内的直接对等通信可在Web浏览器之间进行语音聊天,视频聊天和文件共享,而无需安装插件或下载本机应用程序。
  • 大概是在2017年左右在技术上很受欢迎的印象

如何实现P2P通信

  • 即使是P2P,它也不直接通信,而是通过服务器通信。
  • SFU(选择性转发单元)=服务器代表客户端接收视频/音频并将其分发给客户端。
  • MCU(多点控制单元)=通过在服务器端合成一种视频/音频来创建和分发。由于传送的视频是合成的,因此CPU负载很高。

当前设备和浏览器支持状态

  • iOS:Safari 11(iOS 11)和更高版本受支持。 WKWebView尚不支持。 SFSafariViewController似乎可以在iOS 13上使用。
  • Android:大约Android6,7(在android chrome中为android7)。由于不支持H264编解码器的视频,因此某些华为终端等可能无法很好地显示视频。
  • 在PC浏览器上,Chrome,Firefox,Safari,Edge等大多数都可以工作。

WebRTC服务

  • Skyweb,vonage,Twilio等将webrtc作为视频通话服务(包括服务器)提供给它,也有一个SDK。
  • 但是这次,我想加深我的理解,同时探讨如何实现Google WebRTC(可能是官方库)。

    • https://webrtc.github.io/webrtc-org/native-code/ios/

样品

  • 这次,我将使用这个精彩的示例来看看实现。

    • https://github.com/stasel/WebRTC-iOS
  • WebRTC正式具有使用Firebase的Web示例代码。
    https://webrtc.org/getting-started/firebase-rtc-codelab

如何实现

连接

  • 信令(ICE候选人(通信路线信息)和SDP之间的中继交换)通过服务器进行通信以建立通信。
  • 在示例中,有一个nodejs服务器
  • 在该示例中,使用了Google发布的STUN(用于NAT遍历的协议)服务器。

连接

  • 创建三个通信线路,AudioTrack / VideoTrack /数据通道。
  • 客户端之间交换要约SDP和应答SDP(称为信令)
  • SDP包含可以使用的兼容视频/音频编解码器,流ID,端口号,IP地址等。
  • SDP交换由WebSocket完成。

SDP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
v=0
o=- 7063950325015941208 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=msid-semantic: WMS stream
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 102 0 8 106 105 13 110 112 113 126
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:Agzu
a=ice-pwd:mef2jhxanVddiHDLNvw7BMl9
a=ice-options:trickle renomination
a=fingerprint:sha-256 D7:E8:97:ED:96:EC:6D:8D:44:35:E8:51:A0:07:A0:EC:57:67:B9:76:16:31:1C:3E:6A:DF:A7:9D:E5:98:80:3A
a=setup:actpass
a=mid:0
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id
a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
a=sendrecv
a=msid:stream audio0
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=rtcp-fb:111 transport-cc
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
a=rtpmap:9 G722/8000
a=rtpmap:102 ILBC/8000
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:106 CN/32000
a=rtpmap:105 CN/16000
a=rtpmap:13 CN/8000
a=rtpmap:110 telephone-event/48000
a=rtpmap:112 telephone-event/32000
a=rtpmap:113 telephone-event/16000
a=rtpmap:126 telephone-event/8000
a=ssrc:2493853505 cname:UcicltjXPgMMZ/9S
a=ssrc:2493853505 msid:stream audio0
a=ssrc:2493853505 mslabel:stream
a=ssrc:2493853505 label:audio0
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 124 125
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:Agzu
a=ice-pwd:mef2jhxanVddiHDLNvw7BMl9
a=ice-options:trickle renomination
a=fingerprint:sha-256 D7:E8:97:ED:96:EC:6D:8D:44:35:E8:51:A0:07:A0:EC:57:67:B9:76:16:31:1C:3E:6A:DF:A7:9D:E5:98:80:3A
a=setup:actpass
a=mid:1
a=extmap:14 urn:ietf:params:rtp-hdrext:toffset
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:13 urn:3gpp:video-orientation
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:12 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
a=extmap:11 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type
a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing
a=extmap:8 http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07
a=extmap:9 http://www.webrtc.org/experiments/rtp-hdrext/color-space
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id
a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
a=sendrecv
a=msid:stream video0
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:96 H264/90000
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c34
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
a=rtpmap:98 H264/90000
a=rtcp-fb:98 goog-remb
a=rtcp-fb:98 transport-cc
a=rtcp-fb:98 ccm fir
a=rtcp-fb:98 nack
a=rtcp-fb:98 nack pli
a=fmtp:98 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e034
a=rtpmap:99 rtx/90000
a=fmtp:99 apt=98
a=rtpmap:100 VP8/90000
a=rtcp-fb:100 goog-remb
a=rtcp-fb:100 transport-cc
a=rtcp-fb:100 ccm fir
a=rtcp-fb:100 nack
a=rtcp-fb:100 nack pli
a=rtpmap:101 rtx/90000
a=fmtp:101 apt=100
a=rtpmap:127 red/90000
a=rtpmap:124 rtx/90000
a=fmtp:124 apt=127
a=rtpmap:125 ulpfec/90000
a=ssrc-group:FID 2153314113 822817334
a=ssrc:2153314113 cname:UcicltjXPgMMZ/9S
a=ssrc:2153314113 msid:stream video0
a=ssrc:2153314113 mslabel:stream
a=ssrc:2153314113 label:video0
a=ssrc:822817334 cname:UcicltjXPgMMZ/9S
a=ssrc:822817334 msid:stream video0
a=ssrc:822817334 mslabel:stream
a=ssrc:822817334 label:video0
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:Agzu
a=ice-pwd:mef2jhxanVddiHDLNvw7BMl9
a=ice-options:trickle renomination
a=fingerprint:sha-256 D7:E8:97:ED:96:EC:6D:8D:44:35:E8:51:A0:07:A0:EC:57:67:B9:76:16:31:1C:3E:6A:DF:A7:9D:E5:98:80:3A
a=setup:actpass
a=mid:2
a=sctp-port:5000
a=max-message-size:262144

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        let config = RTCConfiguration()
        config.iceServers = [RTCIceServer(urlStrings: iceServers)]

        // Unified plan is more superior than planB
        config.sdpSemantics = .unifiedPlan

        // gatherContinually will let WebRTC to listen to any network changes and send any new candidates to the other client
        config.continualGatheringPolicy = .gatherContinually

        let constraints = RTCMediaConstraints(mandatoryConstraints: nil,
                                              optionalConstraints: ["DtlsSrtpKeyAgreement":kRTCMediaConstraintsValueTrue])
        self.peerConnection = WebRTCClient.factory.peerConnection(with: config, constraints: constraints, delegate: nil)

        super.init()
        self.createMediaSenders()
        self.configureAudioSession()
        self.peerConnection.delegate = self

优惠SDP

  • 发送的第一个SDP。
  • 此时,可以传输视频编解码器:H264,VP8等。
1
2
3
4
5
6
7
8
9
10
11
12
13
    func offer(completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
        let constrains = RTCMediaConstraints(mandatoryConstraints: self.mediaConstrains,
                                             optionalConstraints: nil)
        self.peerConnection.offer(for: constrains) { (sdp, error) in
            guard let sdp = sdp else {
                return
            }

            self.peerConnection.setLocalDescription(sdp, completionHandler: { (error) in
                completion(sdp)
            })
        }
    }

回答SDP

  • 接收并返回报价SDP的SDP。
  • 它接收视频编解码器并支持VP8等发送。
1
2
3
4
5
6
7
8
9
10
11
12
13
    func answer(completion: @escaping (_ sdp: RTCSessionDescription) -> Void)  {
        let constrains = RTCMediaConstraints(mandatoryConstraints: self.mediaConstrains,
                                             optionalConstraints: nil)
        self.peerConnection.answer(for: constrains) { (sdp, error) in
            guard let sdp = sdp else {
                return
            }

            self.peerConnection.setLocalDescription(sdp, completionHandler: { (error) in
                completion(sdp)
            })
        }
    }

视频捕获和传输开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
    func startCaptureLocalVideo(renderer: RTCVideoRenderer) {
        // 接続時に準備したカメラの映像を取得して映像フレームを生成するインスタンス
        guard let capturer = self.videoCapturer as? RTCCameraVideoCapturer else {
            return
        }

        // ビデオキャプチャ対応しているデバイス(フロントカメラ)を取得
        guard
            let frontCamera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == .front }),

            // フロントカメラの最大解像度を取得
            let format = (RTCCameraVideoCapturer.supportedFormats(for: frontCamera).sorted { (f1, f2) -> Bool in
                let width1 = CMVideoFormatDescriptionGetDimensions(f1.formatDescription).width
                let width2 = CMVideoFormatDescriptionGetDimensions(f2.formatDescription).width
                return width1 < width2
            }).last,

            // 最大フレームレートを取得
            let fps = (format.videoSupportedFrameRateRanges.sorted { return $0.maxFrameRate < $1.maxFrameRate }.last) else {
            return
        }

        // フロントカメラのキャプチャ開始、相手への送信開始
        capturer.startCapture(with: frontCamera,
                              format: format,
                              fps: Int(fps.maxFrameRate))

        // 自分の映像を描画
        // localVideoTrackは接続時に準備しているローカル側の映像トラックなので、localVideoTrackに入ってくるデータをrendererに流し込むための設定
        self.localVideoTrack?.add(renderer)
    }

视频渲染集

1
2
3
4
5
    func renderRemoteVideo(to renderer: RTCVideoRenderer) {
        // 相手の映像を描画
        // remoteVideoTrackは接続時に準備している相手側からの受信映像トラックなので、remoteVideoTrackに入ってくるデータをrendererに流し込むための設定
        self.remoteVideoTrack?.add(renderer)
    }

当您想在视频上显示图像

当您想在视频上显示图像

有以下方法。

  • 一种将您发送的视频帧转换为图像的模式
  • 使用DataChannel的模式

模式

,使您发送的视频帧成为图像

  • 基本上,实现了GoogleWebRTC,以便将视频从前置/后置摄像头发送到另一方。
  • 但是,由于有一个委托(RTCVideoCapturerDelegate)用于挂钩要在每一帧发送给另一方的视频,因此请使用它。
  • 这时,使用RTCVideoSource#capturer()传递RTCVideoFrame。
  • 稍后将描述实现。这不是示例,而是原始实现。

使用DataChannel的模式

  • RTCDataBuffer(处理二进制数据的类)可以在数据通道中发送和接收,因此请使用它。
  • 发送此数据后,图像将显示在发送方和接收方,并确定并处理规格。

使您发送的视频帧成为图像

原来是这样..

1
2
self.videoSource = WebRTCClient.factory.videoSource()
self.videoCapturer = RTCCameraVideoCapturer(delegate: self.videoSource)

使它看起来像这样。

1
2
3
4
5
6
7
8
9
10
11
12
self.videoSource = WebRTCClient.factory.videoSource()

//self.videoCapturer = RTCCameraVideoCapturer(delegate: self.videoSource)
self.videoCapturer = RTCCameraVideoCapturer(delegate: self) //selfはRTCVideoCapturerDelegateを継承

// WebRTCClient WebRTCの処理を行うためのクラス
extension WebRTCClient: RTCVideoCapturerDelegate {
    // これが映像フレーム送信前にコールバックされる。
    func capturer(_ capturer: RTCVideoCapturer, didCapture frame: RTCVideoFrame) {
        self.captureVideoFrameChannel(videoSource: self.videoSource, videoCapturer: capturer, srcframe: frame)
    }
}

从图像创建框架并将其发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
    private func captureVideoFrameChannel(videoSource: RTCVideoSource, videoCapturer: RTCVideoCapturer) {
        let image = UIImage(named: "gundam")!

        func cvPixelBuffer(image: UIImage) -> CVPixelBuffer?
        {
            let width = image.cgImage!.width
            let height = image.cgImage!.height
            let options: [NSObject: Any] = [
                            kCVPixelBufferCGImageCompatibilityKey: true,
                            kCVPixelBufferCGBitmapContextCompatibilityKey: true,
                            ]
            var pxbufferTemp: CVPixelBuffer? = nil
            let status = CVPixelBufferCreate(kCFAllocatorDefault, width,
                                             height, kCVPixelFormatType_32ARGB, options as CFDictionary,
                &pxbufferTemp);
            guard let pxbuffer = pxbufferTemp, status == kCVReturnSuccess else {
                fatalError()
            }

            CVPixelBufferLockBaseAddress(pxbuffer, [])
            let pxdataTmp = CVPixelBufferGetBaseAddress(pxbuffer)
            guard let pxdata = pxdataTmp else {
                fatalError()
            }

            let rgbColorSpace = CGColorSpaceCreateDeviceRGB();
            guard let  context = CGContext(data: pxdata, width: width,
                                           height: height, bitsPerComponent: 8, bytesPerRow: 4 * width, space: rgbColorSpace,
                                           bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue) else {
                    fatalError()
            }
            context.draw(image.cgImage!, in: CGRect(origin: .zero, size: CGSize.init(width: width, height: height)))
            CVPixelBufferUnlockBaseAddress(pxbuffer, CVPixelBufferLockFlags(rawValue: 0))

            return pxbuffer
        }

        func cmSampleBuffer(image: UIImage) -> CMSampleBuffer {
            let pixelBuffer = cvPixelBuffer(image: image)
            var newSampleBuffer: CMSampleBuffer? = nil
            var timimgInfo: CMSampleTimingInfo = CMSampleTimingInfo.invalid
            var videoInfo: CMVideoFormatDescription? = nil
            CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer!, formatDescriptionOut: &videoInfo)
            CMSampleBufferCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer!, dataReady: true, makeDataReadyCallback: nil, refcon: nil, formatDescription: videoInfo!, sampleTiming: &timimgInfo, sampleBufferOut: &newSampleBuffer)
            return newSampleBuffer!
        }

        let pixelBuffer = CMSampleBufferGetImageBuffer(cmSampleBuffer(image: image))!
        let rtcpixelBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer)
        // これが映像フレームのデータ
        let videoFrame = RTCVideoFrame(
            buffer: rtcpixelBuffer,
            rotation: RTCVideoRotation._0,
            timeStampNs: Int64(Date().timeIntervalSince1970 * 1_000_000_000)
        )
        // ここで映像データ送信!
        videoSource.capturer(videoCapturer, didCapture: videoFrame)
    }
  • 当发送视频帧时,发送方还会发送时间戳,接收方会根据时间戳(可能)进行渲染。
  • 因此,如果RTC VideoFrame时间戳不正确,则不会在接收端显示。

终于

  • 也许还没有针对iOS应用的易于使用的客户端库(可能)
  • 有包括服务器在内的各种服务,因此相对易于使用。
  • 我想看音频实现更多..

这次使用的源

https://github.com/HikaruSato/WebRTC-iOS/tree/tutorial

参考(谢谢!)

  • https://gist.github.com/szktty/999a34c64cc4ea60de43c4c1adc93203
  • https://gist.github.com/voluntas/67e5a26915751226fdcf
  • https://qiita.com/mush/items/121e45fefed009b6ad5e