基于Kurento搭建WebRTC服务器
Kurento是一个WebRTC媒体服务器,同时提供了一系列的客户端API,可以简化供浏览器、移动平台使用的视频类应用程序的开发。Kurento支持:
- 群组通信(group communications)
- 媒体流的转码(transcoding)、录制(recording)、广播(broadcasting)、路由(routing)
- 高级媒体处理特性,包括:机器视觉(CV)、视频索引、增强现实(AR)、语音分析
Kurento的模块化架构使其与第三方媒体处理算法 —— 语音识别、人脸识别 —— 很容易集成。
和大部分多媒体通信技术一样,Kurento应用的整体架构包含两个层(layer)或者叫平面(plane):
- 信号平面(Signaling Plane):负责通信的管理,例如媒体协商、QoS、呼叫建立、身份验证等
- 媒体平面(Media Plane):负责媒体传输、编解码等
典型Kurento应用的整体架构如下图:
按分层的方式来划分,Kurento应用可以分为三层(类似于典型的Web应用):
- 展现层 —— 浏览器、移动应用、其它媒体源等应用客户端:
- 基于任意协议和应用逻辑层通信,发起信号处理
- 基于RTP/HTTP/WebRTC协议和KMS通信:
- 通过KMS的输入端点,传输媒体流到KMS
- 通过KMS的输出端点,从KMS获得媒体流
- 应用逻辑层——应用服务器负责信号平面:
- 基于WebSocket/HTTP/REST/SIP等方式和应用客户端通信,进行信号处理
- 内嵌Kurento Client,基于Kurento Protocol与KMS通信,管理媒体元素/媒体管线
- 服务层——KMS负责媒体平面,可以对输入流进行各种处理,并产生输出流
媒体协商(信号处理)阶段:
- 客户端首先向应服务器请求某种媒体特性(例如请求一个九画面视频监控流、请求发布自己的SDP)。这块WebRTC没有规定,可以基于任何协议(HTTP/WS/SIP)实现
- 应用服务器接收到请求后,执行特定的服务器端逻辑,例如AAA(认证授权审计)、CDR生成等
- 应用服务器处理请求,并命令KMS实例化适当的媒体元素、构建媒体流(例如从多个RTSP源混合出九画面)
- 媒体流构建完毕后,KMS应答应用服务器,后者应答客户端,告知其如何获取媒体服务
媒体交换阶段:
- 客户端利用协商阶段收集的信息,向KMS发起请求(例如向目标端口发起UDP请求,获取九画面视频监控流)
下图是交互的序列示意,注意先后顺序:
Kurento允许基于WebRTC建立浏览器和KMS之间的实时多媒体会话:
- 客户端基于SDP来发布自己的媒体特性,请求发送给应用服务器
- 应用服务器根据SDP来创建合适的WebRTC端点,并请求KMS生成一个响应SDP
- 应用服务器获得响应SDP后,将其返回给客户端
- 由于双方都知道对方的SDP了,客户端和KMS可以进行媒体交换了
下图是交互的序列示意:
Kurento也可以作为一个媒体代理,让浏览器之间建立直接的媒体交换。交互序列仍然如上图,仅仅是KMS返回的SDP不同
WebRTC让浏览器能够进行实时的点对点通信(在没有服务器的情况下)。但是要想实现群组通信、媒体流录制、媒体广播、转码等高级特性,没有媒体服务器是很难实现的。
Kurento的核心是一个媒体服务器(Kurento Media Server,KMS),负责媒体的传输、处理、加载、录制,主要基于 GStreamer实现。此媒体服务器的特性包括:
- 网络流协议处理,包括HTTP、RTP、WebRTC
- 支持媒体混合(mixing)、路由和分发的群组通信(MCU、SFU功能)
- 对机器视觉和增强现实过滤器的一般性支持
- 媒体存储支持,支持对WebM、MP4进行录像操作,可以播放任何GStreamer支持的视频格式
- 对于GStreamer支持的编码格式,可以进行任意的转码,例如VP8, H.264, H.263, AMR, OPUS, Speex, G.711
KMS基于模块化的设计,模块主要分为三类:
- 核心(kms-core)
- 媒体元素(kms-elements)
- 过滤器(kms-filters)
- 其它增强KMS的模块,例如kms-crowddetector, kms-pointerdetector, kms-chroma, kms-platedetector
KMS允许用户扩展自己的模块。
Kurento Protocol是一个网络协议,通过WebSocket暴露KMS的特性。
Kurento API是对上述协议的OO封装,通过此API能够创建媒体元素和管线。Kurento提供了API的Java、JavaScript绑定。
Kurento提供了Java、JavaScript(包括浏览器和Node.js)的客户端库,通过这些库你可以控制媒体服务器。对于其它编程语言,可以使用 Kurento Protocol协议(基于WebSocket/JSON-RPC)。
Kurento客户端API基于所谓媒体元素(Media Element)的概念。一个每天元素持有一种特定的媒体特性。例如:
- 媒体元素WebRtcEndpoint的特性是,接收WebRTC媒体流
- 媒体元素RecorderEndpoint的特性是,将接收到的媒体流录制到文件系统
- 媒体元素FaceOverlayFilter则能够检测人脸,在其上方显示一个特定的图像
开箱即用的媒体元素如下图:
从开发者角度来说,操控媒体元素就好像搭积木。 你只需要按照期望的拓扑结构把它们连接起来就可以了。一系列连接起来的媒体元素称为媒体管线(Media Pipeline)。只有一个管线内部的媒体元素才能相互通信
当创建管道时,开发者需要明确希望使用到的特性,以及媒体连接(connectivity) —— 产生媒体的元素和消费媒体的元素之间的连接:
1 2 3 |
sourceMediaElement.connect(sinkMediaElement); // 例如:客户端接收WebRTC流并录制到媒体服务器的文件系统 webRtcEndpoint.connect(recorderEndpoint); |
为了简化浏览器客户端的WebRTC流处理,Kurento提供了工具WebRtcPeer,你仍然可以使用WebRTC的标准API,以及连接到WebRtcEndpoint。
你可以在64位Ubuntu 14.04 LTS上安装KMS:
1 2 3 4 5 6 7 8 9 |
docker create -it -h kurento --name kurento --network local --dns 172.21.0.1 --ip 172.21.0.6 docker.gmem.cc/ubuntu:14.04 bash # 在上述容器中执行 echo "deb http://ubuntu.kurento.org trusty kms6" | sudo tee /etc/apt/sources.list.d/kurento.list wget -O - http://ubuntu.kurento.org/kurento.gpg.key | sudo apt-key add - sudo apt-get update # 执行下面的命令安装KMS,可能需要手工选择依赖冲突处理方案 aptitude install kurento-media-server-6.0 # 选择降级gcc-4.8-base、libstdc++6的那个方案 |
要启动或者停止KMS服务,执行下面的命令:
1 2 3 4 |
# 启动服务 sudo service kurento-media-server-6.0 start # 停止服务 sudo service kurento-media-server-6.0 stop |
Trickle ICE是对ICE的扩展,它允许ICE代理(KMS、客户端)增量的收发candidates而不是交换完整的candidate列表。
由于使用了Trickle ICE协议, 目前的6.0版本的KMS和5.1-版本不兼容,你需要卸载老版本后重新安装:
1 2 3 |
sudo apt-get remove kurento-media-server sudo apt-get purge kurento-media-server sudo apt-get autoremove |
注意sources.list文件和sources.list.d下的文件中,对kurento的引用也要删除。
在Ubuntu:14.04容器中安装后,关闭容器,提交为镜像:
1 |
docker commit kurento docker.gmem.cc/kurento:base |
新建一个Docker项目:
1 2 3 4 5 6 |
FROM docker.gmem.cc/kurento:base ADD /fs / RUN chmod +x /entrypoint.sh CMD ["/entrypoint.sh"] |
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 |
#!/usr/bin/env bash sighdl() { echo echo -e "\033[44mKilling sub process $pid \033[0m" kill -TERM $pid echo -e "\033[44mStopping KMS \033[0m" service kurento-media-server-6.0 stop echo -e "\033[44mCleaning up log files \033[0m" rm -rf /var/log/kurento-media-server/* } trap sighdl HUP INT PIPE QUIT TERM service kurento-media-server-6.0 start sleep 10 kmspid=`ps -A | grep kurento-media | xargs |cut -d" " -f1` pushd /var/log/kurento-media-server > /dev/null logfile=`find . -name "*pid$kmspid.log" | head -n 1` # 持续输出当前日志的内容,确保容器不退出 tail -f $logfile & pid=$! # 捕获到信号的时候,下面的命令退出 —— 等待被中断 wait $pid # 信号处理完毕后,执行下面的命令,如果tail这个子进程已经终止,则wait会立即退出 # 如果子进程正在处理TERM信号,则等待其处理完毕后,wait退出 # 如果没有这个double wait,则子进程有可能成为僵尸,因为没有父进程实际完成wait系统调用 wait $pid |
构建新镜像: docker build --force-rm -t docker.gmem.cc/kurento .
创建基于新镜像容器:
1 2 |
docker create --name kurento -h kurento --dns 172.21.0.1 --network local --ip 172.21.0.6 --expose 8888 \ -p 8888:8888 docker.gmem.cc/kurento |
启动容器:
1 |
docker start -i kurento |
要自己构建Kurento,可以参考本节的操作步骤。本节记录的操作步骤是在Ubuntu 14.04 TLS上执行的。
kms-filters依赖于此库:
1 2 3 4 5 6 7 8 9 10 11 |
pushd /home/alex/CPP/lib > /dev/null mkdir opencv pushd opencv > /dev/null wget https://codeload.github.com/opencv/opencv/zip/2.4.13.3 -O 2.4.13.zip unzip -o -d . 2.4.13.zip mv opencv-2.4.13.3 2.4.13 mkdir build pushd build > /dev/null cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/home/alex/CPP/lib/opencv/2.4.13 .. make && make install popd && popd |
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 119 120 121 122 123 124 125 126 127 128 129 |
# Kurento的组件和源码安装在此 export KURENTO_HOME=/home/alex/CPP/lib/kurento # 构建Kurento组件的通用CMake选项 export CMAKE_OPTS="-DCMAKE_INSTALL_PREFIX:STRING=$KURENTO_HOME -DCMAKE_MODULE_PATH:STRING=$KURENTO_HOME/share/cmake-2.8/Modules" export BOOST_ROOT=/home/alex/CPP/lib/boost/1.65.1 pushd /home/alex/CPP/lib/kurento/src > /dev/null # 构建kms-cmake-utils git clone https://github.com/Kurento/kms-cmake-utils.git pushd kms-cmake-utils > /dev/null mkdir build pushd build > /dev/null cmake $CMAKE_OPTS .. make && make install popd && popd # 构建kurento-module-creator git clone https://github.com/Kurento/kurento-module-creator.git pushd kurento-module-creator > /dev/null mvn package # CMake模块统一存放处 cp target/classes/FindKurentoModuleCreator.cmake $KURENTO_HOME/share/cmake-2.8/Modules/ mkdir $KURENTO_HOME/kurento-module-creator cp target/kurento-module-creator-jar-with-dependencies.jar $KURENTO_HOME/kurento-module-creator/ cp scripts/kurento-module-creator $KURENTO_HOME/kurento-module-creator/ export PATH=$PATH:$KURENTO_HOME/kurento-module-creator popd # 构建kms-jsonrpc git clone https://github.com/Kurento/jsoncpp.git pushd jsoncpp > /dev/null mkdir build pushd build > /dev/null # 需要修改$KURENTO_HOME/src/jsoncpp/src/lib_json/CMakeLists.txt # 添加目标属性 SET_TARGET_PROPERTIES(jsoncpp_lib_static PROPERTIES COMPILE_FLAGS "-fPIC") # 否则kms-jsonrpc的构建会报错 ...can not be used when making a shared object; recompile with -fPIC cmake $CMAKE_OPTS .. make && make install popd && popd git clone https://github.com/Kurento/kms-jsonrpc.git pushd kms-jsonrpc > /dev/null mkdir build pushd build > /dev/null # 下一步会报错 package 'kmsjsoncpp>=0.6.0' not found # 实际上我们刚刚构建好kmsjsoncpp,其Package config位于$KURENTO_HOME/lib/pkgconfig目录下 export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$KURENTO_HOME/lib/pkgconfig cmake $CMAKE_OPTS .. export LIBRARY_PATH=$LIBRARY_PATH:$KURENTO_HOME/lib # 报错 fatal error: json/json.h: No such file or directory # 经过检查,使用的jsoncpp头文件路径是/home/alex/CPP/lib/kurento/include/kmsjsoncpp # 而实际路径是/home/alex/CPP/lib/kurento/include # 这是jsoncpp项目的pkgconfig模板错误导致, # 手工修改$KURENTO_HOME/lib/pkgconfig/kmsjsoncpp.pc最后一行为Cflags: -I${includedir} make && make install popd && popd # 构建kms-core # 后续可能需要调试 export CMAKE_OPTS="$CMAKE_OPTS -DCMAKE_BUILD_TYPE:STRING=Debug" sudo apt install libvpx-dev # Kurento使用自己打包的gstreamer echo "deb http://ubuntu.kurento.org trusty kms6" | sudo tee /etc/apt/sources.list.d/kurento.list wget -O - http://ubuntu.kurento.org/kurento.gpg.key | sudo apt-key add - sudo apt-get update sudo apt install libgstreamer1.5-dev libgstreamer-plugins-base1.5-dev git clone https://github.com/Kurento/kms-core.git pushd kms-core > /dev/null mkdir build pushd build > /dev/null cmake $CMAKE_OPTS .. # 构建时又找不到BOOST头文件 export CPATH=$CPATH:/home/alex/CPP/lib/boost/1.65.1/include make && make install popd && popd # 构建kms-elements export CMAKE_OPTS="$CMAKE_OPTS -DKURENTO_MODULES_DIR:STRING=$KURENTO_HOME/share/kurento/modules" sudo apt-get install libusrsctp* git clone https://github.com/Kurento/openwebrtc-gst-plugins.git pushd openwebrtc-gst-plugins > /dev/null ./autogen.sh ./configure --prefix=$KURENTO_HOME make && make install popd sudo apt-get install libnice-dev git clone https://github.com/Kurento/kms-elements.git pushd kms-elements > /dev/null mkdir build pushd build > /dev/null cmake $CMAKE_OPTS .. export CPATH=$CPATH:$KURENTO_HOME/include/gstreamer-1.5 make && make install popd && popd # 构建kms-filters git clone https://github.com/Kurento/kms-filters.git pushd kms-filters > /dev/null mkdir build pushd build > /dev/null export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/home/alex/CPP/lib/opencv/2.4.13/lib/pkgconfig export LIBRARY_PATH=$LIBRARY_PATH:/home/alex/CPP/lib/opencv/2.4.13/lib export CPATH=$CPATH:/home/alex/CPP/lib/opencv/2.4.13/include # 修改CMake配置/home/alex/CPP/lib/kurento/src/kms-filters/CMakeLists.txt # 第29-30行,去掉 -Wall -Werror cmake $CMAKE_OPTS .. make && make install popd && popd # 构建 kurento-media-server git clone https://github.com/Kurento/kurento-media-server.git pushd kurento-media-server > /dev/null mkdir build pushd build > /dev/null cmake $CMAKE_OPTS .. export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/alex/CPP/lib/kurento/lib/x86_64-linux-gnu make && make install popd && popd |
运行,要运行自己构建的KMS,参考如下脚本:
1 2 3 4 5 6 |
#!/bin/bash cd /home/alex/CPP/lib/kurento/bin export CLIB_HOME=/home/alex/CPP/lib export KURENTO_HOME=$CLIB_HOME/kurento export LD_LIBRARY_PATH=$CLIB_HOME/boost/1.65.1/lib:$KURENTO_HOME/lib:$KURENTO_HOME/lib/x86_64-linux-gnu ./kurento-media-server -f $KURENTO_HOME/etc/kurento/kurento.conf.json -c $KURENTO_HOME/etc/kurento/modules/kurento |
KMS的主配置文件位于/etc/kurento/kurento.conf.json,内容如下:
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 |
{ "mediaServer" : { "resources": { // 当请求创建一个对象时,如果资源用量达到下面的阈值,抛出异常 "exceptionLimit": "0.8", // 如果没有任何存活对象,且资源用量达到下面的阈值,则重启服务器 "killLimit": "0.7", // 垃圾回收器活动间隔(秒) "garbageCollectorPeriod": 240 }, "net" : { // WS用于Kurento Protocol "websocket": { // 普通WS端口 "port": 8888, // WSS端口、数字证书信息 "secure": { "port": 8433, "certificate": "defaultCertificate.pem", "password": "" }, "registrar": { "address": "ws://localhost:9090", "localAddress": "localhost" }, // URL路径 "path": "kurento", "threads": 10 } } } } |
此外还有以下配置文件:
媒体元素的一般性参数:/etc/kurento/modules/kurento/MediaElement.conf.json |
SDP端点(WebRtcEndpoint、RtpEndpoint)的音视频参数 /etc/kurento/modules/kurento/SdpEndpoint.conf.json |
WebRtcEndpoint专有参数:/etc/kurento/modules/kurento/WebRtcEndpoint.conf.json |
HttpEndpoint专有参数:/etc/kurento/modules/kurento/HttpEndpoint.conf.json |
如果KMS位于NAT设备后面,你需要使用STUN或者TURN以便实现NAT穿透。大部分情况下STUN足够,在对称NAT的情况下才需要使用TURN。
要启用STUN支持,修改配置文件:
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 |
; 解除注释: stunServerAddress=<serverAddress> stunServerPort=<serverPort> ; 公网有很多免费的STUN服务: ; 173.194.66.127:19302 ; 173.194.71.127:19302 ; 74.125.200.127:19302 ; 74.125.204.127:19302 ; 173.194.72.127:19302 ; 74.125.23.127:3478 ; 77.72.174.163:3478 ; 77.72.174.165:3478 ; 77.72.174.167:3478 ; 77.72.174.161:3478 ; 208.97.25.20:3478 ; 62.71.2.168:3478 ; 212.227.67.194:3478 ; 212.227.67.195:3478 ; 107.23.150.92:3478 ; 77.72.169.155:3478 ; 77.72.169.156:3478 ; 77.72.169.164:3478 ; 77.72.169.166:3478 ; 77.72.174.162:3478 ; 77.72.174.164:3478 ; 77.72.174.166:3478 ; 77.72.174.160:3478 ; 54.172.47.69:3478 |
要启用TURN支持,解除注释:
1 |
turnURL=user:password@address:port |
一个开源的TURN实现是coturn
KMS的日志默认存放在/var/log/kurento-media-server/目录下:
- media-server_<timestamp>.<log_number>.<kms_pid>.log为本次运行的KMS日志
- media-server_error.log为第三方错误日志
- logs子目录存放历史日志
Kurento提供了Java/JavaScript的API,对于其它编程语言,目前需要通过WebSocket/JSON-RPC使用Kurento Protocol。
本章仅仅进行概念上的阐述,如果需要了解针对具体语言的API,请参阅官方文档:
- kurento-client-java:Java客户端
- kurento-client-js:JavaScript客户端
- kurento-utils-js:用于简化WebRTC应用开发的JavaScript工具
Kurento的主要类型的类图如下,可以看到MediaObject是所有类型的根,并且实现了组合模式:
媒体元素和媒体管线是最核心的API。
MediaElement是媒体流中,执行特定动作的功能单元。它让媒体特性对于应用开发者表现为自包含的黑盒,这些开发者不需要了解底层细节。
MediaElement可以通过mediaSrcs从其它媒体元素接收媒体,或者通过mediaSinks将媒体发送给其它媒体元素。
根据功能的不同,MediaElement可以分为:
- 输入媒体元素:支持接收媒体,并将媒体注入到管线中。这类媒体元素有多种,实现从文件、网络、摄像头等来源读取媒体流
- 过滤器:能够转换、分析媒体流,实现混合、AR之类的功能
- HubPort:Hub负责管理管线中的多个媒体流。每个Hub有多个HubPort,这些HubPort连接其它媒体元素
- 输出媒体元素:支持输出媒体,将媒体流带出管线。实现录像、在屏幕上播放、发送到网络等功能
MediaElement常常由Endpoint实现,后者可能同时作为输入、输出元素。
MediaPipeline是MediaElement构成的链条。链条可以有多个作为入口点的输入元素。由一个元素生成的输出流(SRC)可能输入到1-N个元素的输入流(SINK):
端点是MediaElement的一种实现,能够输入、输出媒体流。端点类层次的类图如下:
这些端点的功能简述如下表:
端点 | 说明 | ||
WebRtcEndpoint | 输入输出端点(能够接受外部输入、也能够输出到外部),实现WebRTC协议 | ||
RtpEndpoint | 输入输出端点,基于SDP进行媒体协商,基于RTP进行流发送 | ||
HttpPostEndpoint | 输入端点,支持类似于HTTP文件上传那样的POST请求 | ||
PlayerEndpoint | 输入端点,支持从文件系统、HTTP URL、RTSP URL接收内容,并将其注入到媒体管线中 | ||
RecorderEndpoint | 输出端点,以可靠的方式存储媒体内容到文件系统。用法示例:
|
/home/alex/CPP/lib/kurento/src/kurento-media-server
关于端点,要注意:
- 这些端点都是在KMS中运行的!尽管你会通过Java/Node的客户端,在应用服务器上操控端点,但是实质上都是基于Kurento协议向KMS发起远程调用
- 端点可能具有SRC、SINK端子,分别用于发送媒体流到其它端点、接受其它端点的发来的媒体流。SRC、SINK是媒体管线内部概念
- 端点可能对外部系统具有接收、发送媒体流的功能(但不叫SRC/SINK),例如WebRtcEndpoint。接收到的媒体流可以通过SRC发送给其它端点,其它端点发送到SINK的媒体流可以转发到外部系统
- 端点自己的SRC可以连接到自己的SINK
代表一个运行在KMS中的WebRTC端点,是这类端点的控制接口。WebRTC端点可以和浏览器中的WebRTC客户端交互。例如环回视频流的那个实例,其媒体流向图如下:
说明如下:
- 摄像头出来视频流,一方面在本地浏览器上渲染
- 另外一方面,发送给KMS中的WebRTCEndpoint端点
- 上一步的媒体流,到达SRC端子,进而发给自己的SINK端子(环回)
- SINK端子的媒体流发回给浏览器
- 浏览器在另外一个video元素中渲染视频流
WebRTC端点是P2P的WebRTC通信的一端,另一端可以是使用RTCPeerConnection接口的浏览器、Native的WebRTC应用程序、甚至是另一个KMS服务器。
为了建立WebRTC通信,两端必须进行SDP协商,其中一方作为邀请者(Offerer)另外一方作为应答者(Offeree),WebRTC端点可以作为两种角色之一。
当作为邀请者时:
- KMS客户端调用generateOffer()方法后,KMS生成一个SDP offer,此Offer返回给KMS客户端(应用服务器),再被转发给浏览器
- 浏览器处理上述Offer,并产生一个应答,应答传递给KMS客户端
- 后者调用processAnswer()导致应答转发给KMS
当作为应答者时:
- 浏览器生成一个SDP offer,发送到KMS客户端
- KMS客户端调用processOffer(),SDP被转发给KMS,KMS生成应答,发送给KMS客户端
- KMS客户端把应答转发给浏览器处理
SDP独立于ICE候选发送。Kurento使用优化了的ICE收发机制 —— Trickle ICE。两端分别、独立的执行收集ICE候选:
- 浏览器中候选会自动收集,你可以使用onicecandidate回调接收通知。此事件常常比SDP处理发生的更快
- KMS必须依赖于客户端调用gatherCandidates(),并在此调用之前注册IceCandidateFound的监听器
KMS、浏览器每收集到一个ICE候选,就(以KMS客户端也就是应用服务器)为中介,发送给对方。接收到对方的ICE候选后,双方就会开始尝试建立双向连接。
需要注意WebRTC信号处理的异步性,假设你希望录制WebRTC端点的视频,在媒体流实际发送之前就执行录制是没有意义的。要感知WebRTC端点的状态,你需要监听端点的事件:
- IceComponentStateChange,在WebRTC点对点连接性发生变化后立即发布。这个事件仅仅能用于检测底层的连接性,处于CONNECTED 状态不意味着媒体流就已经开始传输。连接性状态包括(RFC5245定义了它们之间的状态转换图):
- DISCONNECTED 没有任何被调度的活动
- GATHERING 开始收集本地(KMS服务器)的ICE候选
- /home/alex/CPP/lib/kurento/src/kurento-media-serverCONNECTING 尝试创建连接,这在接收到对方的ICE候选后触发
- CONNECTED 至少一个有效的ICE候选对出现,导致双向连接成功
- READY ICE结束,候选对选择完成
- FAILED 连接性检查已经完毕,但是媒体流连接没有建立
- IceCandidateFound,一旦新的ICE候选可用即触发,这些候选必须被发送给对方
- IceGatheringDone,所有ICE候选都被收集完毕后触发
- NewCandidatePairSelected,当新的ICE候选对(本地、远程)可用时触发,当媒体会话已经进行后,此事件仍然可以触发 —— 一个更高优先级的ICE候选对被发现时
- DataChannelOpen,数据通道打开时
- DataChannelClose,数据通道关闭后
流控制、拥塞管理是WebRTC最重要的一项功能。WebRTC连接总是以一个较低的带宽开始,慢慢的加大到最大可用带宽。WebRTC 端点如果服务多个外部连接,那么它们将共享一个码流质量,这意味着一个新的外部连接接入后,现有连接的码流质量会下降(因为要从较低带宽开始)。
默认的带宽范围取值在100kbps-500kbps之间,可以单独设置SRC/SINK、音频/视频的带宽范围:
- setMin/MaxVideoRecvBandwidth() 设置接收视频带宽
- setMin/MaxAudioRecvBandwidth() 设置接收音频带宽
- setMin/MaxVideoSendBandwidth() 设置发送视频带宽
带宽最大值在SDP中有声明。
WebRTC可以提供数据通道,并且支持可靠/不可靠、有序/无序的数据传输。要支持数据通道,必须在创建WebRtcEndpoint时显式说明,默认是不允许创建数据通道的
此端点从可Seek/不可Seek的媒体源中获取媒体流,并将流注入到KMS中。支持的URL格式:
- 挂载到本地文件系统的文件:file:///path/to/file
- 提供RTSP协议的摄像头:rtsp://、rtsp://username:password@ip:port...
- Web服务器:http(s):///path/to/file、http(s)://username:password@/path/to/file
此端点支持以下操作:
操作 | 说明 |
play | 开始播放媒体流,可以在pause后调用,恢复播放 |
stop | 停止播放媒体流 |
pause | 暂停播放媒体流 |
setPosition/getPosition | 如果媒体源支持,可以用来执行seek操作 |
这类媒体元素负责媒体的处理、机器视觉、AR等功能。 这些媒体元素的功能简述如下表:
过滤器 | 说明 | ||
ZBarFilter | 检测二维码(QR)、条形码,一旦检测成功,就发布一个CodeFoundEvent事件。客户端可以侦听此事件并执行相应的操作 | ||
FaceOverlayFilter | 检测人脸,叠加一个可配置的图像。用法示例:
|
||
GStreamerFilter | 允许你在Kurento中使用GStreamer过滤器 |
这类媒体对象能够管理多个媒体流。这些媒体对象的功能简述如下表:
Hub | 说明 |
Composite |
能够混合多个输入音频流 能够合并多个输入视频流,构成多画面 |
DispatcherOneToMany | 把一个输入HubPort分发给所有输出HubPort |
Dispatcher | 运行在任意输入-输出HubPort对值之间路由 |
Utils JS用于简化浏览器端WebRTC应用的开发。
执行下面的命令安装:
1 2 3 4 |
# 基于NPM npm install kurento-utils # 基于Bower bower install kurento-utils |
或者到这里下载压缩后的JS文件。
WebRtcPeer对RTCPeerConnection进行了包装。连接可以是单向的(进行发送或者接收),也可以是双向的(同时发送接收)。
下面的例子示意了如何基于Utils JS创建一个RTCPeerConnection,并与其它Peer进行会话协商:
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 |
// 信号处理通道,由你自己决定如何实现,它能够让客户端知道可以和谁通信、如何通信 // 典型的做法是,所有客户端公开一个自己的名字,同时以一条WebSocket连接到服务器 // 客户端通过名字发起通信请求,服务器负责中介会话协商 var signalingChannel = createSignalingChannel(peerName); // 用于显示远程视频的元素 var videoInput = document.getElementById( 'videoInput' ); // 用于显示本地视频的元素 var videoOutput = document.getElementById( 'videoOutput' ); // getUserMedia约束条件 var constraints = { audio: true, video: { width: 640, framerate: 15 } }; var options = { localVideo: videoInput, remoteVideo: videoOutput, onicecandidate: function( candidate ){ // 把本地candidate发送给Peer,基于Trickle ICE,也就是说,一旦发现一个候选,就立即发送 // 不等待所有候选收集成功,这样效率更高。此回调可能被调用多次 signalingChannel.sendCandidate(candidate ); }, mediaConstraints: constraints }; // 创建一个连接。注意,在双方都需要创建连接,创建的时机,就是服务器确认了两者要进行通信之后 var webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv( options, function ( error ) { // 处理失败 if ( error ) return onError( error ); // 生成本地的SDP Offer this.generateOffer( onOffer ); } ); // 当收到Peer的candidate后,添加。下面的代码应该在信号处理的回调中调用 webRtcPeer.addIceCandidate(candidate); // 当本地SDP Offer生成后,调用此回调 function onOffer( error, sdpOffer ) { if ( error ) return onError( error ); // 发送SDP给Peer,Peer应该给出SDP应答,然后本地调用sdpAnswer回调 signalingChannel.sendOffer( sdpOffer, sdpAnswer ); function onAnswer( sdpAnswer ) { webRtcPeer.processAnswer( sdpAnswer ); }; } |
简述一下上例对应的业务流程:
- 通信发起方A,根据接受方B的标识符,向服务器发送WS请求 —— 我要和B通信
- 服务器通过WS推送信息给B,A想和你通信,你愿意吗?
- 如果B愿意,服务器通过WS推送消息给A、B,你们可以通信了
- A、B分别创建连接对象(WebRtcPeer)
- WebRtcPeer会自动收集Candidate,你应该通过WS把Candidate发回服务器,服务器再中转给Peer
- 一单A、B都收集到Candidate,它们就有可能进行点对点通信了(如果是局域网内)
- A发起(Offer)一个会话描述(SDP),B接收到后,给出Answer
- 根据双方的SDP,建立媒体流交换
数据通道允许你通过活动WebRTC连接传递二进制、文本数据。WebRtcPeer对数据通道的使用也提供了封装,将dataChannels选项设置为true即可使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var options = { localVideo: videoInput, remoteVideo: videoOutput, // 启用数据通道 dataChannels: true, // 下面这个配置是可选的,允许你执行一些声明周期回调 dataChannelConfig: { id: getChannelName(), onmessage: onMessage, onopen: onOpen, onclose: onClosed, onbufferedamountlow: onbufferedamountlow, onerror: onerror }, onicecandidate: onIceCandidate } webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv( options, onWebRtcPeerCreated ); |
一旦webRtcPeer对象被创建,你就可以调用下面的方法,通过数据通道发送信息:
1 2 |
// 发送的数据类型取决于应用 webRtcPeer.send('Hello there'); |
数据通道的生命周期受限于其依赖的连接, webRtcPeer.dispose()被调用后数据通道也被关闭和释放。
Kurento是一个可拔插的框架,它的每个插件称为模块。模块分为三类。
这类模块安装了KMS就可以使用,包括:
模块 | 说明 |
kms-core | KMS的核心功能,基于C编写 |
kms-elements | 实现媒体元素,例如WebRtcEndpoint、WebRtcEndpoint |
kms-filters | 实现过滤器,例如FaceOverlayFilter, ZBarFilter, GStreamerFilter |
这些模块用于增强KMS的基本功能,没有随KMS安装,包括:
模块 | 说明 | ||
kms-pointerdetector |
一个过滤器,基于颜色追踪在视频流中检测点(pointers),执行下面的命令安装:
|
||
kms-chroma |
一个过滤器,在一个层上让指定的色彩范围变得透明,这样下面层的图像就会显示出来。执行下面的命令安装:
|
||
kms-crowddetector |
过滤器,能够检测人群聚集。执行下面的命令安装:
|
||
kms-platedetector |
过滤器,能够实现车牌检测。执行下面的命令安装:
|
你可以根据需要自己扩展KMS模块。
这是一个环回视频流的例子 —— 视频流发送给自己,需要一台客户端即可测试。通信流程如下:
- 页面加载时,客户端自动创建一个到服务器的wss连接:
1var ws = new WebSocket('wss://' + location.host + '/helloworld');信号处理依赖此wss连接进行,信号格式为JSON,其id字段表示消息的类型。
- 用户点击页面上的开始按钮,执行下面的逻辑:
123456789101112131415161718192021var options = {// 显示本地流的元素localVideo : videoInput,// 显示远程流的元素remoteVideo : videoOutput,// 当候选通信地址可用时,执行的回调onicecandidate : onIceCandidate}// 连接对象创建后执行的回调function( err ){if ( err ) console.error( err );// 生成SDP,成功后执行回调webRtcPeer.generateOffer( function( error, offerSdp ) {ws.send( JSON.stringify( {id : 'start',sdpOffer : offerSdp} ) );});}// 创建具有收、发能力的连接对象webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv( options, callback );也就是说,作为通信发起方:
- 创建一个连接对象WebRtcPeerSendrecv,此对象创建后,本地流立即就显示在localVideo这个video标签中
- 创建完毕后,即生成SDP,其内容如下(主要是发起方允许的连接方式、支持的媒体特性):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120v=0# 第一个数字是会话标识,第二个数字是会话版本。后续三个参数和会话协商无关:网络类型Internet,地址类型IPv4,产生SDP的机器的地址o=- 6324724567974172241 2 IN IP4 127.0.0.1# 会话的名称,不常用s=-# 会话起止时间,都为0表示不限制时间t=0 0# BUNDLE分组将多个媒体行关联起来,在WebRTC中用于在同一RTP会话中传递多个媒体流a=group:BUNDLE audio video# 在PeerConnection声明周期中,赋予WebRTC媒体流唯一标识a=msid-semantic: WMS g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t### 音频行 #### m表示这是一个媒体行,audio表示这是音频,后面是协议,最后的长串数字为媒体格式说明m=audio 38968 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 126# c表示这是一个连接行,表示收发数据通过什么IP进行。但是由于WebRTC强制使用ICE,因此这一行没什么用c=IN IP4 192.168.56.1# 明确说明用于RTCP的地址和端口a=rtcp:51004 IN IP4 192.168.56.1# 下面若干行都是ICE候选,ICE是用于NAT穿透的协议# 标识 1RTP/2RTCP 优先级 通信地址和端口a=candidate:2999745851 1 udp 2122260223 192.168.56.1 38968 typ host generation 0a=candidate:364622241 1 udp 2122194687 10.255.0.1 49487 typ host generation 0a=candidate:1051995033 1 udp 2122129151 172.18.0.1 52714 typ host generation 0a=candidate:410389623 1 udp 2122063615 172.21.0.1 54819 typ host generation 0a=candidate:2199032595 1 udp 2121998079 192.168.1.89 47718 typ host generation 0a=candidate:627415207 1 udp 2121932543 192.168.0.89 52455 typ host generation 0a=candidate:2999745851 2 udp 2122260222 192.168.56.1 51004 typ host generation 0a=candidate:364622241 2 udp 2122194686 10.255.0.1 59954 typ host generation 0a=candidate:1051995033 2 udp 2122129150 172.18.0.1 41985 typ host generation 0a=candidate:410389623 2 udp 2122063614 172.21.0.1 59234 typ host generation 0a=candidate:2199032595 2 udp 2121998078 192.168.1.89 58222 typ host generation 0a=candidate:627415207 2 udp 2121932542 192.168.0.89 36590 typ host generation 0# 下面两行是ICE参数a=ice-ufrag:Oyu3vwR19M1nxsx4a=ice-pwd:8RbNWdv799Hz7aXWj2DMIPGH# 下面两行是DTLS参数# DTLS-SRTP协商时使用的证书的指纹信息a=fingerprint:sha-256 58:BC:1A:0B:22:10:95:7B:C9:98:4A:D5:34:E9:44:85:FF:9D:A4:7B:07:39:36:FE:90:59:E0:14:3D:B9:21:6Ca=setup:actpass# 用在BUNDLE中的标识符a=mid:audio# 定义RTP扩展头a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-levela=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time# 同时支持接收、发送a=sendrecv# 支持RTCP多路复用a=rtcp-mux# 解码器参数a=rtpmap:111 opus/48000/2a=rtcp-fb:111 transport-cca=fmtp:111 minptime=10; useinbandfec=1a=rtpmap:103 ISAC/16000a=rtpmap:104 ISAC/32000a=rtpmap:9 G722/8000a=rtpmap:0 PCMU/8000a=rtpmap:8 PCMA/8000a=rtpmap:106 CN/32000a=rtpmap:105 CN/16000a=rtpmap:13 CN/8000a=rtpmap:126 telephone-event/8000a=maxptime:60# SSRC参数a=ssrc:2978616353 cname:GrA29DQMxaUfd99ua=ssrc:2978616353 msid:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t 97776675-4490-4b74-a849-bbd46a722c89a=ssrc:2978616353 mslabel:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9ta=ssrc:2978616353 label:97776675-4490-4b74-a849-bbd46a722c89m=video 46497 UDP/TLS/RTP/SAVPF 100 101 116 117 96 97 98c=IN IP4 192.168.56.1a=rtcp:9 IN IP4 0.0.0.0a=candidate:2999745851 1 udp 2122260223 192.168.56.1 46497 typ host generation 0a=candidate:364622241 1 udp 2122194687 10.255.0.1 34284 typ host generation 0a=ice-ufrag:Oyu3vwR19M1nxsx4a=ice-pwd:8RbNWdv799Hz7aXWj2DMIPGHa=fingerprint:sha-256 58:BC:1A:0B:22:10:95:7B:C9:98:4A:D5:34:E9:44:85:FF:9D:A4:7B:07:39:36:FE:90:59:E0:14:3D:B9:21:6Ca=setup:actpass### 视频行 ###a=mid:videoa=extmap:2 urn:ietf:params:rtp-hdrext:toffseta=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-timea=extmap:4 urn:3gpp:video-orientationa=sendrecva=rtcp-mux# 支持的视频编码a=rtpmap:100 VP8/90000# 如果客户端是Firefox、Chrome 61 —— 支持H264a=rtpmap:120 VP8/90000a=rtpmap:126 H264/90000a=rtpmap:97 H264/90000# 则出现以上三行a=rtcp-fb:100 ccm fira=rtcp-fb:100 nacka=rtcp-fb:100 nack plia=rtcp-fb:100 goog-remba=rtcp-fb:100 transport-cca=rtpmap:101 VP9/90000a=rtcp-fb:101 ccm fira=rtcp-fb:101 nacka=rtcp-fb:101 nack plia=rtcp-fb:101 goog-remba=rtcp-fb:101 transport-cca=rtpmap:116 red/90000a=rtpmap:117 ulpfec/90000a=rtpmap:96 rtx/90000a=fmtp:96 apt=100a=rtpmap:97 rtx/90000a=fmtp:97 apt=101a=rtpmap:98 rtx/90000a=fmtp:98 apt=116a=ssrc-group:FID 3977515695 1979665708a=ssrc:3977515695 cname:GrA29DQMxaUfd99ua=ssrc:3977515695 msid:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t 153f4d5f-ba5b-4772-8700-aff4474d8652a=ssrc:3977515695 mslabel:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9ta=ssrc:3977515695 label:153f4d5f-ba5b-4772-8700-aff4474d8652a=ssrc:1979665708 cname:GrA29DQMxaUfd99ua=ssrc:1979665708 msid:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t 153f4d5f-ba5b-4772-8700-aff4474d8652a=ssrc:1979665708 mslabel:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9ta=ssrc:1979665708 label:153f4d5f-ba5b-4772-8700-aff4474d8652 - 上述SDP以消息类型start发送给服务器
- 服务器接收到start消息后,执行以下逻辑:
123456789101112131415161718192021222324252627282930313233343536373839404142434445// 创建媒体管线MediaPipeline pipeline = kurento.createMediaPipeline();// 在管线中添加一个WebRTC端点WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder(pipeline).build();// 连接WebRTC端点到自己webRtcEndpoint.connect(webRtcEndpoint);// 创建一个用户会话(UserSession不属于KMS 客户端API的组成部分)UserSession user = new UserSession();// 连接到的管线user.setMediaPipeline(pipeline);// 连接到的端点,注意此端点的输入、输出是同一个流user.setWebRtcEndpoint(webRtcEndpoint);// 以WebSocket会话标识时别用户https://localhost:8443/#users.put(session.getId(), user);// 处理SDPString sdpOffer = jsonMessage.get("sdpOffer").getAsString();// 由端点来处理SDP,生成应答String sdpAnswer = webRtcEndpoint.processOffer(sdpOffer);// 以消息类型startResponse将应答SDP通过WebSocketSession发回给客户端JsonObject response = new JsonObject();response.addProperty("id", "startResponse");response.addProperty("sdpAnswer", sdpAnswer);synchronized (session) {session.sendMessage(new TextMessage(response.toString()));}// 一旦收集到服务器的ICE候选信息,即以消息类型iceCandidate发送给客户端webRtcEndpoint.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {@Overridepublic void onEvent(IceCandidateFoundEvent event) {JsonObject response = new JsonObject();response.addProperty("id", "iceCandidate");response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));try {synchronized (session) {session.sendMessage(new TextMessage(response.toString()));}} catch (IOException e) {log.error(e.getMessage());}}});// 为某个端点收集服务器的ICE候选信息webRtcEndpoint.gatherCandidates();服务器生成的SDP应答内容如下:
123456789101112131415161718192021222324252627282930313233343536373839v=0o=- 3713658153 3713658153 IN IP4 0.0.0.0s=Kurento Media Serverc=IN IP4 0.0.0.0t=0 0a=msid-semantic: WMS kGkOSxP0iFTu9aRzm53BNz0fROtBq1HxLFjea=group:BUNDLE audio videom=audio 1 UDP/TLS/RTP/SAVPF 111 0a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-timea=mid:audioa=rtcp:9 IN IP4 0.0.0.0a=rtpmap:111 opus/48000/2a=rtpmap:0 PCMU/8000a=setup:activea=sendrecva=rtcp-muxa=fmtp:111 minptime=10; useinbandfec=1a=maxptime:60a=ssrc:1475810019 cname:user35735626@host-c1cf1e49a=ice-ufrag:/Jmla=ice-pwd:RCpQ+o7Ybof5B5mxYDGM17a=fingerprint:sha-256 B4:72:A8:44:90:3D:CF:1B:8E:30:93:09:AC:66:BF:05:60:D7:0B:C3:C3:AA:28:7D:44:46:8E:55:17:61:4F:43m=video 1 UDP/TLS/RTP/SAVPF 100a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-timea=mid:videoa=rtcp:9 IN IP4 0.0.0.0# 使用VP8作为视频编码格式a=rtpmap:100 VP8/90000a=rtcp-fb:100 ccm fira=rtcp-fb:100 nacka=rtcp-fb:100 nack plia=rtcp-fb:100 goog-remba=setup:activea=sendrecva=rtcp-muxa=ssrc:101029323 cname:user35735626@host-c1cf1e49a=ice-ufrag:/Jmla=ice-pwd:RCpQ+o7Ybof5B5mxYDGM17a=fingerprint:sha-256 B4:72:A8:44:90:3D:CF:1B:8E:30:93:09:AC:66:BF:05:60:D7:0B:C3:C3:AA:28:7D:44:46:8E:55:17:61:4F:43 - 客户端接收到startResponse消息后,调用下面的方法处理SDP应答:
123webRtcPeer.processAnswer(message.sdpAnswer, function(error) {if (error) console.error(error);}); - 关于Ice Candidate的处理上面没有提及,这会异步的进行:
- 客户端连接创建后,就会自动收集ICE候选,一旦收集到,就调用如下回调:
123456function onIceCandidate(candidate) {ws.send(JSON.stringify({id : 'onIceCandidate',candidate : candidate}));}候选的内容如下:
12345678{// 此候选的通信地址"candidate":"candidate:2999745851 1 udp 2122260223 192.168.56.1 36777 typ host generation 0 ufrag waE0gMnNFX3ug+yW",// 此候选关联的媒体流的标识(identification-tag)"sdpMid":"audio",// 此候选关联SDP中媒体描述的索引"sdpMLineIndex":0}也就是说,以消息类型onIceCandidate发送给服务器
- 服务器接收到onIceCandidate消息后,将其保存到用户对象中:
1234567UserSession user = users.get(session.getId());IceCandidate candidate = new IceCandidate(jsonCandidate.get("candidate").getAsString(),jsonCandidate.get("sdpMid").getAsString(),jsonCandidate.get("sdpMLineIndex").getAsInt());user.addCandidate(candidate); -
随着客户端候选的收集,onIceCandidate消息会被发送很多次,后续的sdpMid可能是video,sdpMLineIndex可能是1
- 服务器端在创建端点后,也同样会自动收集ICE候选信息,并以iceCandidate消息发送给客户端。候选的内容如下:
12345{"candidate": "candidate:5 1 TCP 1019216383 172.21.0.6 9 typ host tcptype active","sdpMid":"video","sdpMLineIndex":1} - 客户端做如下处理:
123webRtcPeer.addIceCandidate(parsedMessage.candidate, function(error) {if (error) console.error(error);}); -
随着服务器端候选的收集, iceCandidate消息也会被发送多次
- 客户端连接创建后,就会自动收集ICE候选,一旦收集到,就调用如下回调:
- 随着候选信息的收集,webRtcPeer有了足够的信息,它会在remoteView元素中渲染远程媒体流
- 当用户点击停止按钮后,调用 webRtcPeer.dispose()并发送一个stop类型的消息
- 服务器收到stop消息后,清理用户数据:
12UserSession user = users.remove(session.getId());user.release();- 释放用户数据的时候,会调用 mediaPipeline.release()释放媒体管线
- 页面卸载时,客户端自动关闭wss连接:
1ws.close();
在这个HelloWorld例子中,媒体流不是简单的由客户端发给自己,而是由服务器中转。也就是说,通信的Peer是服务器、客户端。
可以在上例的Loopback媒体管线上插入一个FaceOverlayFilter,在检测到人脸时,附加一个帽子图片到人脸上方:
1 2 3 4 5 6 7 8 9 10 11 12 |
UserSession user = new UserSession(); MediaPipeline pipeline = kurento.createMediaPipeline(); WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder(pipeline).build(); user.setWebRtcEndpoint(webRtcEndpoint); // 注意媒体管线在KMS中运行 FaceOverlayFilter faceOverlayFilter = new FaceOverlayFilter.Builder(pipeline).build(); faceOverlayFilter.setOverlayedImage(“https://172.21.0.1:8443/img/mario-wings.png", -0.35F, -1.2F, 1.6F, 1.6F); // 连接WebRTC端点的SRC(输出)到FaceOverlayFilter的SINK(输入) webRtcEndpoint.connect(faceOverlayFilter); // 连接FaceOverlayFilter的SRC(输出)到WebRTC的SINK(输入) faceOverlayFilter.connect(webRtcEndpoint); |
首先初始化连接:
1 2 3 4 5 6 7 8 9 10 11 12 |
// 仅仅需要发送数据,不需要接收 webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendonly( { localVideo : video, onicecandidate : function(){ /* 发送本地ICE候选信息给服务器 */ } }, function( err ){ webRtcPeer.generateOffer(function( err, offerSdp ){ /* 发送SDP,消息类型presenter */ }); } ) |
服务器接收到presenter消息后,会发送一个presenterResponse消息过来。如果服务器同意当前客户端作为发布者,则发布者调用:
1 2 |
webRtcPeer.processAnswer(message.sdpAnswer); // 否则关闭连接 |
服务器发来的ICE候选消息的处理,和前面的例子一样。
首先也是初始化连接:
1 2 3 4 5 |
webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly( options, function( err ){ this.generateOffer(function(){ /* 发送SDP,消息类型viewer */ }); }); |
viewerResponse、 服务器发来的ICE候选消息的处理,和发布者一样。
当服务器接收到发布者发来的presenter消息时,执行:
- 记录一个发布者的会话对象,本质上是基于WS客户端标识对发布者进行时别
- 创建媒体管线:
123456789101112131415pipeline = kurento.createMediaPipeline();// 设置发布者的端点对象presenterUserSession.setWebRtcEndpoint(new WebRtcEndpoint.Builder(pipeline).build());// 当服务器的ICE候选准备好之后,发送给发布者客户端:presenterUserSession.getWebRtcEndpoint().addIceCandidateFoundListener( e-> {// 作为iceCandidate事件发送});// 处理发布者的SDPString sdpOffer = jsonMessage.getAsJsonPrimitive("sdpOffer").getAsString();String sdpAnswer = presenterWebRtc.processOffer(sdpOffer);// 然后以presenterResponse消息发送SDP应答给发布者// 最后,为发布者收集ICE候选信息presenterWebRtc.gatherCandidates(); - 当接收到发布者的ICE候选后,把这些信息记录到代表发布者的会话对象中:
1presenterUserSession.addCandidate(cand); // 处理方式和HelloWorld那个例子相同
到目前为止,尚未发生任何媒体流的传输工作。因为没有人查看者。
当有查看者接入后,服务器首先收到一个viewer信息,并执行:
- 如果当前没有发布者,返回viewerResponse消息,其response属性为rejected
- 如果当前有发布者,则为其创建UserSession对象、WebRtcEndpoint端点,并发此端点加入到之前创建的管线中:
123456789101112131415161718UserSession viewer = new UserSession(webSocketSession);WebRtcEndpoint nextWebRtc = new WebRtcEndpoint.Builder(pipeline).build();viewer.setWebRtcEndpoint(nextWebRtc);viewer.getWebRtcEndpoint().addIceCandidateFoundListener( e-> {// 作为iceCandidate事件发送});// 重要:将发布者的SRC连接到查看者的SINKpresenterUserSession.getWebRtcEndpoint().connect(nextWebRtc);// 处理查看者的SDPString sdpOffer = jsonMessage.getAsJsonPrimitive("sdpOffer").getAsString();// SDP应答总是调用请求者的端点对象获得String sdpAnswer = nextWebRtc.processOffer(sdpOffer);// 然后以viewerResponse消息发送SDP应答给发布者// 最后,为观看者收集ICE候选信息nextWebRtc.gatherCandidates();
当由更多的查看者连接进来后,发布者端点的SRC将连接到更多的SINK,呈现出星状结构。从ICE候选信息来看,貌似媒体流都是从服务器中转的。
这个在实现上没有特别的地方,参与通话双方的WebRTC端点,需要配置为首尾相连。
此外,业务逻辑部分需要实现拒接之类的功能。
相当于每个参与者都进行一对多广播。在实现时,往往会抽象出会议房间(Group)的概念,房间内的每个人都需要对其它人进行广播。
每个参与者都需要创建一个发送端点,N-1个接收端点,一共N个video元素。
此外,一旦有新人加入、旧人退出,就需要通知房间的所有参与者,进行客户端资源清理、UI更新。
这类应用场景中,媒体流的来源主要有两类:
- 基于ONVIF框架协议,视频流基于RTSP/RTP传输
- 由设备SDK提供,SDK可能提供标准格式的码流、视频帧,或者解码后的原始图像
视频监控的主要需求包括:
- 实时监控,特别是多画面实时监控
- 录像回放
- 视频分析,例如移动侦测、模式识别
为了简化开发,我们对Kurento、信号处理进行了组件化封装。
代表一个WebRTC客户端与Kurento的媒体会话:
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 |
package cc.gmem.study.kurento; import org.kurento.client.Endpoint; import org.kurento.client.IceCandidate; import org.kurento.client.MediaPipeline; import org.kurento.client.WebRtcEndpoint; import java.security.Principal; import java.util.ArrayList; import java.util.List; public class MediaSession { private String id; private Principal principal; private MediaPipeline pipeline; private WebRtcEndpoint endpoint; private List<IceCandidate> candidatesPending; public MediaSession( String id ) { this.id = id; candidatesPending = new ArrayList<>(); } public MediaPipeline getPipeline() { return pipeline; } public void setPipeline( MediaPipeline pipeline ) { this.pipeline = pipeline; } public Endpoint getEndpoint() { return endpoint; } public synchronized void setEndpoint( WebRtcEndpoint endpoint ) { this.endpoint = endpoint; // ICE可能在端点创建之前就送达 if ( candidatesPending != null ) { candidatesPending.forEach( cp -> { endpoint.addIceCandidate( cp ); } ); candidatesPending = null; } } @Override public String toString() { return String.format( "id = %s ep = %s pp = %s", getId(), getEndpoint(), getPipeline() ); } public String getId() { return id; } public Principal getPrincipal() { return principal; } public void setPrincipal( Principal principal ) { this.principal = principal; } public synchronized void addIceCandidate( IceCandidate candidate ) { // ICE可能在端点创建之前就送达 if ( endpoint == null ) { candidatesPending.add( candidate ); } else { endpoint.addIceCandidate( candidate ); } } } |
Spring Boot应用程序,信号处理以STOMP作为子协议:
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 |
package cc.gmem.study.kurento; import org.kurento.client.KurentoClient; import org.kurento.client.KurentoClientBuilder; import org.kurento.client.MediaPipeline; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptorAdapter; import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.AntPathMatcher; import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import sun.security.acl.PrincipalImpl; import java.security.Principal; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @SpringBootApplication @EnableWebSocketMessageBroker public class VideoSurveillanceApp extends AbstractWebSocketMessageBrokerConfigurer { private static final Logger LOGGER = LoggerFactory.getLogger( VideoSurveillanceApp.class ); private Map<String, MediaSession> sessions = new ConcurrentHashMap<>(); public void registerStompEndpoints( StompEndpointRegistry registry ) { // 信号处理在 /signal下进行 registry.addEndpoint( "/signal" ); } @Override public void configureMessageBroker( MessageBrokerRegistry registry ) { registry.setApplicationDestinationPrefixes( "/app" ); } @Override public void configureClientInboundChannel( ChannelRegistration registration ) { registration.setInterceptors( new ChannelInterceptorAdapter() { @Override public Message<?> preSend( Message<?> message, MessageChannel channel ) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor( message, StompHeaderAccessor.class ); String simpSessionId = (String) accessor.getHeader( "simpSessionId" ); MediaSession session = getMideaSession( simpSessionId ); if ( StompCommand.CONNECT.equals( accessor.getCommand() ) ) { // 设置当前用户身份 String login = accessor.getNativeHeader( "login" ).get( 0 ); Principal principal = new PrincipalImpl( login ); accessor.setUser( principal ); session.setPrincipal( principal ); LOGGER.info( "User {} connected with session id {}", login, simpSessionId ); } // 每次处理消息之前,设置session头,便于消息处理方法注入之 accessor.setHeader( "session", session ); return message; } } ); } private MediaSession getMideaSession( String simpSessionId ) { if ( sessions.containsKey( simpSessionId ) ) { return sessions.get( simpSessionId ); } else { MediaSession session = new MediaSession( simpSessionId ); sessions.put( simpSessionId, session ); return session; } } @Bean public KurentoClient kurentoClient() { return new KurentoClientBuilder().setKmsWsUri( "ws://172.21.0.6:8888/kurento" ).connect(); } public static void main( String[] args ) { new SpringApplication( VideoSurveillanceApp.class ).run( args ); } } |
封装一些模板代码:
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 |
package cc.gmem.study.kurento; import org.kurento.client.KurentoClient; import org.kurento.client.MediaPipeline; import org.kurento.client.WebRtcEndpoint; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import javax.inject.Inject; @Service public class KurentoService { @Inject private KurentoClient client; @Inject private SimpMessagingTemplate template; /** * 初始化一个媒体管线 * * @return */ public MediaPipeline createMediaPipeline() { return client.createMediaPipeline(); } /** * 在媒体管线上创建一个与WebRTC浏览器客户端通信的端点 * * @param pipeline 管线 * @param sdpoffer 浏览器发送来的SDP邀请 * @param user 浏览器的身份 * @return 运行在KMS中的WebRTC端点 */ public WebRtcEndpoint createWebRtcEndpoint( MediaPipeline pipeline, String sdpoffer, String user, String namespace ) { WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder( pipeline ).build(); // 处理SDP String sdpAnswer = webRtcEndpoint.processOffer( sdpoffer ); template.convertAndSendToUser( user, namespace + "/sdpanswer", sdpAnswer ); // 处理ICE候选 webRtcEndpoint.addIceCandidateFoundListener( event -> { String dest = namespace + "/icecandidate"; template.convertAndSendToUser( user, dest, event.getCandidate() ); } ); webRtcEndpoint.gatherCandidates(); return webRtcEndpoint; } } |
对stomp.js进行简单的封装:
- 每个客户端在一个名字空间内操作
- 订阅总是针对/user前缀进行
- 发送总是针对/app前缀进行
代码如下:
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 |
class StompClient { /** * 选项: * url,WebSocket连接地址 * namespace,不包含/app、/user的目的地前缀 * login,用户名 * passcode,密码 */ constructor( options ) { this.namespace = options.namespace || {}; this.pending = []; this.stomp = Stomp.over( new WebSocket( options.url ) ); this.stomp.heartbeat.outgoing = 20000; this.stomp.connect( options.login, options.passcode, ( frame ) => { this.connected = true; this.processPending(); } ); } processPending() { if ( this.connected ) { let pending = this.pending; this.pending = []; pending.forEach( callback => callback() ); } } recv( destination, callback ) { this.pending.push( () => { this.stomp.subscribe( '/user' + this.namespace + destination, ( frame ) => { callback( this.decode( frame.body, frame.headers[ 'content-type' ] ), frame ); } ); } ); this.processPending(); } encode( obj ) { return JSON.stringify( obj ); } decode( str, mimeType ) { // 自动分析MIME类型,进行适当的解析 if ( mimeType.startsWith( 'application/json;' ) ) { return JSON.parse( str ); } else { return str; } } send( destination, object ) { this.pending.push( () => { this.stomp.send( '/app' + this.namespace + destination, { "content-type": "application/json;charset=UTF-8" }, this.encode( object ) ); } ); this.processPending(); } disconnect() { this.stomp.disconnect(); } } |
对Kurento Utils的WebRtcPeer进行封装。
WebRTCEndpoint的STOMP消息目的地格式: 前缀 + 名字空间 + 消息类型。消息类型包括:
- sdpoffer,表示浏览器客户端发起SDP邀请
- sdpanswer,表示KMS客户端发给浏览器的SDP应答
- icecandidate,双方交换ICE候选
- stop,客户端请求停止会话
- 其它消息类型
代码如下:
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 |
class WebRTCEndpoint { constructor( mode, options ) { /** * 选项: * remoteVideo,显示远程视频流的元素 */ options = options || {}; let stomp = new StompClient( { url: options.url, namespace: options.namespace, login: options.login } ); let webRtcPeerType; switch ( mode ) { case WebRTCEndpoint.MODE_SEND: webRtcPeerType = kurentoUtils.WebRtcPeer.WebRtcPeerSendonly; break; case WebRTCEndpoint.MODE_RECV: webRtcPeerType = kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly; break; case WebRTCEndpoint.MODE_SEND_RECV: webRtcPeerType = kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv; break; } stomp.recv( '/icecandidate', candidate => { this.peer.addIceCandidate( candidate ); } ); stomp.recv( '/sdpanswer', answer => { this.peer.processAnswer( answer ); } ); options.onicecandidate = candidate => { stomp.send( '/icecandidate', candidate ); } this.peer = webRtcPeerType( options, err => { this.peer.generateOffer( ( error, sdpOffer ) => { stomp.send( '/sdpoffer', sdpOffer ); } ); } ); this.stomp = stomp; } dispose() { this.stomp.send( '/stop', "bye" ); this.stomp.disconnect(); this.peer && this.peer.dispose(); } } WebRTCEndpoint.MODE_SEND = 0; WebRTCEndpoint.MODE_RECV = 1; WebRTCEndpoint.MODE_SEND_RECV = 2; |
以下情况下可以考虑RTSP接入:
- IP摄像头或者NVR直接提供流RTSP协议服务器
- 通过SDK获取码流,手工创建RTSP协议服务器
对于第二种方式,还可以考虑利用Kurento的RTPEndpint,直接通过RTP协议发送媒体流到KMS。
IP摄像头常常会提供某种基于流的接入方式:
- RTSP/H.264:这类摄像头通常用在视频监控领域。它们通过RTSP协议来建立RTP媒体会话 —— 信号处理基于RTSP进行而媒体流直接通过RTP传输。不同的摄像头厂商支持的RTP profile可能不同,AVP(用于音视频会议的RTP profile,最小化控制。RTP Profile for Audio and Video Conferences
with Minimal Control)是一种常用的profile。视频编码方式也有不同的选择,典型的是 H.264 - HTTP/MJPEG:这类摄像头基于HTTP协议进行信号处理和媒体传输,视频流被编码为JPEG的序列。这类摄像头的硬件比较简单,资源(包括电量)消耗少但是视频质量差
要实现WebRTC到IP摄像头的媒体互操作性,两者的码流格式必须兼容,这种码流转换的工作是由某种WebRTC网关负责的(例如Kurento)。此网关需要完成:
- 和摄像头交互,也就是网关需要理解RTSP/RTP或者HTTP
- 解码从摄像头取得的码流,例如H264或者MJPEG
- 将码流重新编为浏览器支持的格式,例如VP8是WebRTC最广泛支持的编码
- 通过WebRTC协议把码流发送给客户端
此工作流示意如下图:
在Chrome中WebRTC使用的视频编码格式一直是VP8/VP9,直到Chrome 50才支持H264。你可以使用标记enable-webrtc-h264-with-openh264-ffmpeg打开H264支持(最新的Chrome 61此标记默认是打开的)。
H264被微软Edge的ORTC、Firefox、移动设备、遗留视频系统支持。移动设备大部分支持H264硬件解码,这意味着播放视频不会过于消耗电池,这一点很关键。
目前的情况并不乐观,主要是不同系统对于H.264的支持程度不同,它们可能支持不兼容的Profile,因而存在互操作性问题。
WebRTC协议栈使用SAVPF这一RTP profile, 其含义是针对基于RTCP的反馈的扩展安全RTP profile(Extended Secure RTP Profile for Real-time Transport Control Protocol Based Feedback),SAVPF主要包括两个RTP profile:
- SAVP:AVP的基础上包含安全特性
- AVPF:用于及时的向媒体流的发送者反馈信息
SAVPF的意义在于,提供安全RTP通信的基础上支持反馈。WebRTC客户端会向WebRTC网关发送反馈信息(在RTCP包中),通知网关可能影响到媒体质量的网络状况。
大多数IP摄像头仅仅支持AVP,这意味着,网关无法把WebRTC的反馈传递给IP摄像头。网关必须自己管理好反馈信息,或者用行话说,网关必须终结(terminate)RTCP反馈。
这一点很重要,如果网关没有正确处理反馈,WebRTC客户端可能出现严重的QoS问题,通常是视频画面卡死。卡死的具体原因是:
- PLI(画面丢失提示,Picture Loss Indication)反馈:如果此反馈没有被网关正确处理,只要出现丢包,画面可能随机的卡死。这和VP8编码器的工作机制有关。VP8允许长时间没有关键帧生成(以分钟计),当PLI出现后网关应该立即生成新的关键帧,否则直到下一次关键帧(周期性的)到达,客户端都无法解码。某些网关的解决方式是,频繁的生成关键帧,这种做法的劣势是大量消耗带宽,导致视频质量差
- REMB(接收者估算的最大比特率, Receiver Estimated Maximum Bitrate)反馈:如果网关没有处理此反馈,且没有任何拥塞控制机制,则网关就不可能指示VP8编码器降低比特率。这样随着接入的客户端便多,网络带宽不够用后,视频质量变差
将Kurento作为WebRTC网关时,上述互操作性问题已经被解决,你需要了解以下三点:
- PlayerEndpoint这个端点支持从各种各样的源读取视频流,这些源可以是RTSP/RTP、HTTP/MJPEG。这意味着PlayerEndpoint有能力从IP摄像头读取码流
- WebRtcEndpoint这个端点支持完整的WebRTC协议栈,能够正确处理RTCP反馈:
- 每当PLI包被收到,WebRtcEndpoint会命令VP8编码器立即生成一个新的关键帧
- 内置了拥塞控制,且响应REMB包。必要时命令VP8编码器降低比特率
- 不可知媒体特性:当两个不兼容的媒体元素连接在一起时,Kurento会自动进行编码格式转换。也就是说H.264/MJPEG到VP8的转码会自动发生,不需要开发人员干预
RTSP到WebRTC的媒体管线示意如下:
客户端代码:
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 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>WebRTC Video Surveillance - RTSP Preview</title> <script src="js/stomp.js"></script> <script src="js/stomp-wrapper.js"></script> <script src="js/adapter.js"></script> <script src="js/kurento-utils.js"></script> <script src="js/webrtc-endpoint.js"></script> </head> <body onunload="endpoint.dispose();"> <div> <video id="remoteVideo" autoplay width="427px" height="240px"></video> </div> <script> let endpoint = new WebRTCEndpoint( WebRTCEndpoint.MODE_RECV, { url: 'ws://172.21.0.1:9090/signal', namespace: '/rtsp/preview', login: new Date().getTime(), remoteVideo: document.getElementById( 'remoteVideo' ) } ); </script> </body> </html> |
服务器代码:
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 |
package cc.gmem.study.kurento; import org.kurento.client.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; import javax.inject.Inject; import java.security.Principal; @Controller @MessageMapping( RtspPreviewController.NAMESPACE ) public class RtspPreviewController { private static final Logger LOGGER = LoggerFactory.getLogger( RtspPreviewController.class ); public static final String NAMESPACE = "/rtsp/preview"; @Inject private KurentoService kurento; @MessageMapping( "/icecandidate" ) public void onIceCandidate( IceCandidate candidate, @Header MediaSession session ) { WebRtcEndpoint endpoint = (WebRtcEndpoint) session.getEndpoint(); session.addIceCandidate( candidate ); return; } @MessageMapping( "/stop" ) public void onStop( @Header MediaSession session ) { session.getEndpoint().release(); session.getPipeline().release(); } @MessageMapping( "/sdpoffer" ) public void onSdpOffer( String sdpoffer, Principal principal, @Header MediaSession session ) { MediaPipeline pipeline = kurento.createMediaPipeline(); session.setPipeline( pipeline ); PlayerEndpoint.Builder peb = new PlayerEndpoint.Builder( pipeline, "rtsp://admin:12345@192.168.0.196:554/ch1/main/av_stream" ); PlayerEndpoint playerEndpoint = peb.build(); playerEndpoint.addMediaFlowInStateChangeListener( e -> { LOGGER.info( "RTSP input flow state changed, media type: {}, media state: {}", e.getMediaType(), e.getState() ); } ); WebRtcEndpoint webRtcEndpoint = kurento.createWebRtcEndpoint( pipeline, sdpoffer, principal.getName(), NAMESPACE ); session.setEndpoint( webRtcEndpoint ); playerEndpoint.connect( webRtcEndpoint ); playerEndpoint.play(); } } |
没有什么本质区别,只有一些技术上的细节需要处理:
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 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>WebRTC Video Surveillance - RTSP Preview</title> <script src="js/stomp.js"></script> <script src="js/stomp-wrapper.js"></script> <script src="js/adapter.js"></script> <script src="js/kurento-utils.js"></script> <script src="js/webrtc-endpoint.js"></script> <style> body { background-color: #02686c; } .nvbar { font-family: sans-serif; height: 30px; display: flex; } .title { font-size: 18px; margin-left: 12px; font-weight: bold; color: rgba(255, 255, 255, .8); text-shadow: 2px 2px 1px rgba(0, 0, 0, .8); line-height: 20px; vertical-align: text-bottom; } .subtitle { font-size: 14px; font-weight: bold; font-style: italic; margin-left: 24px; color: rgba(255, 255, 255, .5); line-height: 20px; vertical-align: text-bottom; } #videos { display: flex; flex-wrap: wrap; } #videos video { margin: 3px; } video { border-radius: 5px; border: 6px solid rgba(0, 0, 0, .5); } video:hover { border: 6px solid rgba(163, 163, 163, 0.5); cursor: pointer; } </style> </head> <body onunload="dispose();"> <div class="nvbar"> <div class="title">基于WebRTC+Kurento的视频监控示例</div> <div class="subtitle">http://172.21.0.1:9090/rtsp-preview.html</div> </div> <div id="videos"></div> <script> let endpoints = []; let videos = document.getElementById( 'videos' ); for ( let ch = 1; ch <= 21; ch++ ) { let video = document.createElement( 'video' ); video.autoplay = true; video.width = 352 / 2; video.height = 288 / 2; videos.appendChild( video ); let endpoint = new WebRTCEndpoint( WebRTCEndpoint.MODE_RECV, { url: 'ws://172.21.0.1:9090/signal', namespace: '/rtsp/preview/ch1', // 因为执行速度太高,之前的new Date().getTime()两次调用会重复 login: Math.random(), remoteVideo: video } ); endpoints.push( endpoint ); } function dispose() { endpoints.forEach( e => e.dispose() ); } </script> </body> </html> |
服务器器端,每个视频通道使用一个名字空间(STOMP目的地中缀):
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 |
package cc.gmem.study.kurento; import org.kurento.client.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.stereotype.Controller; import javax.inject.Inject; import java.security.Principal; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Controller @MessageMapping( RtspPreviewController.NSP ) public class RtspPreviewController { private static final Logger LOGGER = LoggerFactory.getLogger( RtspPreviewController.class ); public static final String NSP = "/rtsp/preview"; @Inject private KurentoService kurento; private Map<String, MediaPipeline> mediaPipelines = new ConcurrentHashMap<>(); @MessageMapping( "/{ch}/icecandidate" ) public void onIceCandidate( @DestinationVariable String ch, IceCandidate candidate, @Header MediaSession session ) { WebRtcEndpoint endpoint = (WebRtcEndpoint) session.getEndpoint(); session.addIceCandidate( candidate ); return; } @MessageMapping( "/{ch}/stop" ) public void onStop( @DestinationVariable String ch, @Header MediaSession session ) { Endpoint endpoint = session.getEndpoint(); // 获取连接到当前端点SINK的那些连接,注意,PlayerEndpoint会创建三个连接过来,分别用于AUDIO、VEDIO、DATA endpoint.getSourceConnections().forEach( data -> { MediaElement source = data.getSource(); MediaElement sink = data.getSink(); source.disconnect( sink ); } ); endpoint.release(); } @MessageMapping( "/{ch}/sdpoffer" ) public void onSdpOffer( @DestinationVariable String ch, String sdpoffer, Principal principal, @Header MediaSession session ) { // 媒体管线现在不是当前会话独占了,而是每个通道一个 PlayerEndpoint playerEndpoint = getPlayerEndpoint( ch ); MediaPipeline pipeline = playerEndpoint.getMediaPipeline(); session.setPipeline( pipeline ); WebRtcEndpoint webRtcEndpoint = kurento.createWebRtcEndpoint( pipeline, sdpoffer, principal.getName(), NSP + '/' + ch ); session.setEndpoint( webRtcEndpoint ); playerEndpoint.connect( webRtcEndpoint ); } private synchronized PlayerEndpoint getPlayerEndpoint( String ch ) { MediaPipeline pipeline = getMediaPipline( ch ); if ( pipeline == null ) { pipeline = kurento.createMediaPipeline(); mediaPipelines.put( ch, pipeline ); PlayerEndpoint.Builder peb = new PlayerEndpoint.Builder( pipeline, getRtspUrlFor( ch ) ); PlayerEndpoint playerEndpoint = peb.build(); playerEndpoint.addMediaFlowInStateChangeListener( e -> { LOGGER.info( "RTSP input flow state changed, media type: {}, media state: {}", e.getMediaType(), e.getState() ); } ); playerEndpoint.play(); return playerEndpoint; } else { PlayerEndpoint playerEndpoint = null; for ( MediaObject mo : pipeline.getChildren() ) { if ( mo instanceof PlayerEndpoint ) { playerEndpoint = (PlayerEndpoint) mo; } } return playerEndpoint; } } private MediaPipeline getMediaPipline( String ch ) { return mediaPipelines.get( ch ); } /** * 获取指定通道的RTSP URL * * @param ch 通道号 * @return */ private String getRtspUrlFor( String ch ) { return "返回此通道的RTSP地址"; } } |
即使开到21画面,客户端运行仍然非常流畅( i7-4940MX ),完全可以满足视频监控领域的多画面需求:
前面我们已经提到过,Chrome 61默认已经开启了H264支持,其它很多浏览器也支持H264。如果KMS不进行转码,则对服务器配置要求可以大大降低。
首先,为Kurento安装插件:
1 |
apt install openh264-gst-plugins-bad-1.5 |
要强制KMS仅仅使用H264,可以修改KMS配置文件,注释掉VP8的支持:
1 2 3 4 5 |
"videoCodecs" : [ { "name" : "H264/90000" } ] |
注意:
- 一定要确保你的客户端都支持基于H264的WebRTC视频传输,才可以进行上述修改
- 进行上述修改后,如果客户端不支持H264,那么SDP应答将会不完整,缺少媒体格式说明:
1m=video 0 UDP/TLS/RTP/SAVPF # 后面缺少媒体格式代码这会导致客户端WebRTC报错:
1Failed to parse SessionDescription. m=video 0 UDP/TLS/RTP/SAVPF Expects at least 4 fields.
通过SDP Offer/Answer可以查看浏览器和KMS协商使用H.264作为视频编码方式。
在浏览器地址栏输入 chrome://webrtc-internals,搜索ssrc_,会发现两个匹配项,其中一个和视频相关。可以看到mediaType为video,codecImplementationName为FFmpeg, googCodecName为H264。
在公司的环境下测试,如果使用子码流的话,Chrome 61、Firefox都可以正常多画面播放。
但是采用主码流的情况下,运行效果实在太差:花屏、周期性卡死:
打开chrome://webrtc-internals可以看到帧经常无法解析(framesDecoded计数不增加)。具体原因还需要深入研究,但我估计可能的相关因素有:
- 根据SDP,摄像头的H.264 Profile是420029,即Baseline;而Chrome支持的H.264 Profile是42e01f,即Constrained Baseline。也就是两者的Profile不兼容。这导致Kurento需要进行转码
- 如果进行SDP伪造,让Kurento相信Chrome支持420029,则完全无法播放。这意味着Chrome可能的确无法解码420029
- 转码工作依赖GST插件openh264-gst-plugins-bad-1.5完成,此插件可能存在质量问题
9月19日更新:
被摄像头给骗了……SDP声称的H.264 Profile和它实际使用的Profile并不一致。默认情况下这款摄像头使用的H.264 Profile为Main,手工配置之后则可以使用Baseline。
不过,就算改成Baseline,和Chrome/Firefox支持的Constrained Baseline仍然不兼容(注意:实际上很多编码器不使用Baseline特性针对Constrained Baseline的差集,也就是说两个Profile的编码结果很可能是兼容的)。进行SDP伪造的话,播放花屏、很快卡死。
很多情况下,监控客户端都开启重要视频通道构成的固定多画面监控。这种情况下,可以考虑在流媒体服务器端把多画面合成GRID(例如四画面、九画面),好处是:
- 降低客户端解码压力
- 降低通信复杂度,不需要开启多个媒体连接甚至信号连接了
更多WebRTC术语参考webrtcglossary。
术语 | 说明 |
ICE |
交互式连接建立(Interactive Connectivity Establishment)是WebRTC进行NAT穿透的标准协议,由IETF RFC 5245定义。取决于candidate,ICE可能尝试直连、STUN、TURN —— ICE负责协调这三种底层连接机制 ICE通过指导连接性检测,来处理基于NATs的的媒体流连接。ICE收集所有可用的候选(candidate,可供Peer连接的地址信息):
所有收集到的candidate通过SDP发送给Peer 一旦WebRTC收集流自己的、Peer的所有ICE地址之后,它就开始初始化连接性测试,逐个通过candidate发送媒体流直到成功 使用ICE的缺点是,会引入延迟(可能高达10s),新协议Trickle ICE用于解决此问题 |
ICE-TCP |
通过TCP而不是TURN来发送媒体流的机制,Chrome支持 |
MCU |
多点会议单元(Multipoint Conferencing Unit) jsonMessage.get("candidate")这种设备提供了在单个视频/音频会话中,连接很多参与者的能力。MCU通常都实现了Mixing架构,因而每个会话都需要消耗很多计算资源 |
Mixing |
一种多点通信架构,每个参与者发送自己的媒体流到中心服务器,并从中心服务器接收混合后的单个媒体流。实现此架构的服务器称为MCU。此架构的:
|
SDP |
会话描述协议(Session Description Protocol),WebRTC使用该协议来协商会话的参数,但是WebRTC不负责信号处理,因而SDP的创建和传输需要应用程序自己完成 |
SFU |
选择性转发单元(Selective Forwarding Unit),有时用于描述一种视频路由设备,有时则用来描述一种路由特性 SFU能够接收多个媒体流,然后决定将其中的哪些流转发给哪些参与者 |
SIP |
会话初始化协议(Session Initiation Protocol),一个在VoIP领域(电信行业)广泛使用的信号处理协议 |
STUN |
NAT用会话穿透工具(Session Traversal Utilities for NAT) 是WebRTC进行NAT穿透的标准方法 STUN的核心目的是,探测客户端的公共地址/端口:
|
Trickle ICE |
对 ICE的优化。ICE的主要瓶颈是初始化连接性检测比较耗时,Trickle ICE通过并行尝试多种底层机制,以加速candidate的获取。一旦某个candidate可用客户端就可以立即进行下一步,不需要等待所有candidate |
TURN |
基于中继的NAT穿透(Traversal Using Relays around NAT)是WebRTC进行NAT穿透的标准方法 当STUN不可用的情况下,TURN基于TURN服务器中继所有媒体流,这可能导致昂贵的流量和CPU开销 |
注册监听器,当流结束后,重新调用play():
1 2 3 |
playerEndpoint.addEndOfStreamListener( e -> { playerEndpoint.play(); } ); |
总结的很仔细,适合初学者学习,感谢分享
你好。想请教一个问题,我这边也遇到了播放 rtsp 卡死的问题,查看 chrome 也是 framesDecoded 不增加,请问你这边怎么解决的,只能换摄像头吗?
是基于WebRTC的方案么,目前我们测试的结果是,Chrome下用VP8、VP9可以流畅播放,仅仅在使用H.264时出现卡死。
卡死的具体原因没有深入研究,有可能是因为Chrome WebRTC对H.264支持不好,或者是Kurento使用的GStremer插件存在问题。
你好,感谢你的回复
我这边除了 Chrome 下测试之外,iOS 端也发现播放不了。
所以现在尝试用 janus 代替,看是否正常,不过 janus 只支持 baseline profile 的编码
janus能够不转码和Web客户端配合进行H.264 over WebRTC么?
我这边用 VP8 也是正常的,只是当摄像头一多起来,CPU 会飙升到 100%,估计是 kurento 将 h264 编码成 VP8 的原因
所以才研究 chrome 和 iOS 直接播放 h264 的方法
嗯,路数多了重编码压力的确大,但是帧率、码率、分辨率都很大程度上影响CPU资源需求。像视频监控领域,多画面的时候都是用低质量的码流的,否则客户端也受不了。
移动设备对H.264的支持应该都很好,不必基于Web技术开发,编码格式不是问题。
Chrome这样的,我目前尝试倒是觉得JS软解码H.264是较为理想的途径,不过找到的解码库也仅支持Baseline。
如何循环播放
注册监听器,当流结束后,重新调用play():
playerEndpoint.addEndOfStreamListener( e -> {
playerEndpoint.play();
} );
如果是file:// 文件则不行。
另外,如何配置kurento 采用VP9编码呢?现在chrome VP9性能更优。
Kurento本身不支持VP9,而且此项目不怎么更新,所以需要更高的可定制性,可以从Kurento的底层GStreamer着手,或选用其它WebRTC服务器
感谢回复。
写的真不错,这方面的中文实践资料真不多,非常感谢。
我想请问一下,各家的 webrtc之间是不是也不兼容呀,这个Kurento是支持那些webrtc呢?
这个问题比较小白,见笑了。
WebRTC兼容性表格请参考:http://iswebrtcreadyyet.com/legacy.html
各家浏览器对音视频编码方式的支持有所差异
Kurento对VP8的支持较好,在服务器端可以自适应的转换为VP8码流。我们之前测试Kurento对H264的支持不是很理想。
请教一下,在源代码中怎么找不到一些类的定义呢?例如MediaObject、MediaElement、ServerType、ServerInfo等等,有什么玄机吗?非常感谢!
你好,这些属于API,Java的代码在 https://github.com/Kurento/kurento-java 项目中
非常感谢!
我今天尝试按照你上面的步骤下载了源码,希望能通过编译源码搭建起kms的环境,目前全部都已经编译通过了,但是在采用上面提到的脚本运行的时候,出现了以下错误:
0:00:00.048094948 6481 0x55af2e706300 ERROR KurentoLoadConfig loadConfig.cpp:237:loadConfig: Error reading configuration: /home/liush/cpp/lib/kurento/etc/kurento/kurento.conf.json(4): expected key string
Error reading configuration: /home/liush/cpp/lib/kurento/etc/kurento/kurento.conf.json(4): expected key string
不知道你这边是否有遇到过,谢谢!
再次感谢Alex的文章,自己编译源码的环境终于跑起来了!
总结一下自己的问题:
1、MediaObject等类的头文件是在编译kurento-module-creator的时候自动生成;
2、运行时出现读取配置文件异常,是由于现在的最新版本6.7.1读取json配置文件,不能有注释行(我把配置文件中的注释行全删除后就能正常运行了),通过apt-get安装的kms-6则就可以有注释行,真奇怪!
是不是说同样是h264,因为profile不一样,也会触发服务器端转码?
因为浏览器支持的是H264的子集,如果不转码它没法播放。
我搜资料看,好像如果直接播放mp4就没有profile的问题,但webrtc会有profile问题
恩,我们这里谈到的编码方式限制,都是针对基于WebRTC的“实时播放”场景的。
kurento是否可以不使用STUN'和TUREN,比如监控只在内网使用 ,也不能连接外网,是不是有办法可以免去安装coturn?
不需要启用,我们的应用场景就是局域网,没有使用过这些特性。
请教一个问题, 网络摄像头一般只支持2, 3 个用户同时读取rtsp流, 能否让服务器只读取一个rtps流, 但支持多个用户播放这个转码后的流?
当然可以,这就是流媒体服务器的功能。它负责接入一台前端设备(摄像头),然后将媒体流分发给多个用户
最新版 6.9.0 跑不起来rtsp源了
这个问题6.9.1解决了
转码过程中能设置分辨率和帧率吗?比如rtsp源是1080p的转码后编程720p的。
你好,我想问一下客户端由于网络问题进行断开连接,有没有什么处理办法
接入摄像头多,如何降低画面质量?
您再次博客中贴出的RTSP接入部分的代码可以发下吗?
您这次博客中贴出的RTSP接入部分的代码可以发下吗?