<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>绿色记忆 &#187; 视频监控</title>
	<atom:link href="https://blog.gmem.cc/tag/%e8%a7%86%e9%a2%91%e7%9b%91%e6%8e%a7/feed" rel="self" type="application/rss+xml" />
	<link>https://blog.gmem.cc</link>
	<description></description>
	<lastBuildDate>Mon, 06 Apr 2026 12:46:48 +0000</lastBuildDate>
	<language>en-US</language>
		<sy:updatePeriod>hourly</sy:updatePeriod>
		<sy:updateFrequency>1</sy:updateFrequency>
	<generator>http://wordpress.org/?v=3.9.14</generator>
	<item>
		<title>基于Broadway的HTML5视频监控</title>
		<link>https://blog.gmem.cc/html5-vs-with-broadway</link>
		<comments>https://blog.gmem.cc/html5-vs-with-broadway#comments</comments>
		<pubDate>Mon, 09 Oct 2017 10:22:08 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[C++]]></category>
		<category><![CDATA[Java]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[Network]]></category>
		<category><![CDATA[Multimedia]]></category>
		<category><![CDATA[视频监控]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=16500</guid>
		<description><![CDATA[<p>简介 Broadway是一个基于JavaScript的H.264解码器，支持Baseline Profile，我们在HTML5视频监控技术预研一文中介绍过这个库。如果你的监控摄像头支持Baseline的H.264码流，利用Broadway可以实现不需要重新编码的视频监控，这样服务器的负载可以大大减轻。 本文不进行理论知识的讨论，仅仅给出一个简单的实现。此实现由三个部分组成： 基于live555的C++程序，用来从视频源取RTP流，解析出NALU然后通过WebSocket推送给WebSocket服务器 基于Spring Boot的Java WebSocket服务器，接收C++程序推送来的NALU并广播给客户端 基于Broadway的HTML5视频监控客户端，为了简化开发，我们使用了Broadway的一个封装http-live-player 代码托管于GitHub：https://github.com/gmemcc/h5vs.git C++部分 这部分主要是一个RTSP客户端，功能上面已经介绍过，此客户端依赖于我以前一篇文章中的live555 RTSP客户端封装。 WebSocket客户端 [crayon-69d6336f05a4e014168586/] [crayon-69d6336f05a53749781112/] 主程序 [crayon-69d6336f05a56174686898/] Java部分 这部分实现了NALU转发功能，基于Spring Boot实现。 主程序 <a class="read-more" href="https://blog.gmem.cc/html5-vs-with-broadway">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/html5-vs-with-broadway">基于Broadway的HTML5视频监控</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h2"><span class="graybg">简介</span></div>
<p>Broadway是一个基于JavaScript的H.264解码器，支持Baseline Profile，我们在<a href="/research-on-html5-video-surveillance">HTML5视频监控技术预研</a>一文中介绍过这个库。如果你的监控摄像头支持Baseline的H.264码流，利用Broadway可以实现不需要重新编码的视频监控，这样服务器的负载可以大大减轻。</p>
<p>本文不进行理论知识的讨论，仅仅给出一个简单的实现。此实现由三个部分组成：</p>
<ol>
<li>基于live555的C++程序，用来从视频源取RTP流，解析出NALU然后通过WebSocket推送给WebSocket服务器</li>
<li>基于Spring Boot的Java WebSocket服务器，接收C++程序推送来的NALU并广播给客户端</li>
<li>基于Broadway的HTML5视频监控客户端，为了简化开发，我们使用了Broadway的一个封装http-live-player</li>
</ol>
<p>代码托管于GitHub：<a href="https://github.com/gmemcc/h5vs.git">https://github.com/gmemcc/h5vs.git</a></p>
<div class="blog_h2"><span class="graybg">C++部分</span></div>
<p>这部分主要是一个RTSP客户端，功能上面已经介绍过，此客户端依赖于我以前一篇文章中的<a href="/realtime-communication-protocols#rtsp-client-wrapper">live555 RTSP客户端封装</a>。</p>
<div class="blog_h3"><span class="graybg">WebSocket客户端</span></div>
<pre class="crayon-plain-tag">//
// Created by alex on 10/9/17.
//

#ifndef LIVE5555_WEBSOCKETCLIENT_H
#define LIVE5555_WEBSOCKETCLIENT_H

#include &lt;pthread.h&gt;

#include &lt;websocketpp/config/asio_no_tls_client.hpp&gt;
#include &lt;websocketpp/client.hpp&gt;

typedef websocketpp::client&lt;websocketpp::config::asio_client&gt; WebSocketppClient;
typedef websocketpp::connection_hdl WebSocketppConnHdl;

class WebSocketClient {
private:
    char *url;
    pthread_t wsThread;
    WebSocketppClient *wsppClient;
    WebSocketppConnHdl wsppConnHdl;
public:
    WebSocketClient( char *url );

    char *getUrl() const;

    virtual void connect();

    virtual void sendBytes( unsigned char *buf, unsigned size );

    virtual void sendText( char *text );

    virtual ~WebSocketClient();

    pthread_t getWsThread() const;

    WebSocketppClient *getWsppClient();

    void setWsppConnHdl( WebSocketppConnHdl wsppConnHdl );
};


#endif //LIVE5555_WEBSOCKETCLIENT_H</pre><br />
<pre class="crayon-plain-tag">//
// Created by alex on 10/9/17.
//

#include "WebSocketClient.h"

using websocketpp::lib::placeholders::_1;
using websocketpp::lib::placeholders::_2;
using websocketpp::lib::bind;

#include "spdlog/spdlog.h"

static auto LOGGER = spdlog::stdout_color_st( "WebSocketClient" );

WebSocketClient::WebSocketClient( char *url ) : url( url ), wsppClient( new WebSocketppClient()) {
}

WebSocketClient::~WebSocketClient() {
    delete wsppClient;
}

static void *wsRoutine( void *arg ) {
    WebSocketClient *client = (WebSocketClient *) arg;

    WebSocketppClient *wsppClient = client-&gt;getWsppClient();
    wsppClient-&gt;clear_access_channels( websocketpp::log::alevel::frame_header );
    wsppClient-&gt;clear_access_channels( websocketpp::log::alevel::frame_payload );
    wsppClient-&gt;init_asio();

    websocketpp::lib::error_code ec;
    WebSocketppClient::connection_ptr con = wsppClient-&gt;get_connection( std::string( client-&gt;getUrl()), ec );
    wsppClient-&gt;connect( con );
    client-&gt;setWsppConnHdl( con-&gt;get_handle());
    wsppClient-&gt;run();
}

void WebSocketClient::connect() {
    pthread_create( &amp;wsThread, NULL, wsRoutine, (void *) this );
}

void WebSocketClient::sendBytes( unsigned char *buf, unsigned size ) {
    wsppClient-&gt;send( wsppConnHdl, buf, size, websocketpp::frame::opcode::BINARY );
}

void WebSocketClient::sendText( char *text ) {
    wsppClient-&gt;send( wsppConnHdl, text, strlen( text ), websocketpp::frame::opcode::TEXT );
}

char *WebSocketClient::getUrl() const {
    return url;
}

pthread_t WebSocketClient::getWsThread() const {
    return wsThread;
}

WebSocketppClient *WebSocketClient::getWsppClient() {
    return wsppClient;
};

void WebSocketClient::setWsppConnHdl( WebSocketppConnHdl wsppConnHdl ) {
    this-&gt;wsppConnHdl = wsppConnHdl;
}</pre>
<div class="blog_h3"><span class="graybg">主程序</span></div>
<pre class="crayon-plain-tag">#include &lt;iostream&gt;
#include "live5555/client.h"

#include "spdlog/spdlog.h"

#include "WebSocketClient.h"

static auto LOGGER = spdlog::stdout_color_st( "wspush" );

class VideoSink : public SinkBase {
private:
#ifdef _SAVE_H264_SEQ
    FILE *os = fopen( "./rtsp.h264", "w" );
#endif
    WebSocketClient *wsClient;
    bool firstFrameWritten;
    const char *sPropParameterSetsStr;
    unsigned char const start_code[4] = { 0x00, 0x00, 0x00, 0x01 };
public:
    VideoSink( UsageEnvironment &amp;env, unsigned int recvBufSize, WebSocketClient *wsClient ) : SinkBase( env, recvBufSize ), wsClient( wsClient ) {
        // 缓冲区前面留出起始码4字节
        recvBuf += sizeof( start_code );
    }

    virtual ~VideoSink() {
    }

    virtual void onMediaSubsessionOpened( MediaSubsession *subSession ) {
        sPropParameterSetsStr = subSession-&gt;fmtp_spropparametersets();
    }

    void afterGettingFrame( unsigned frameSize, unsigned numTruncatedBytes, struct timeval presentationTime ) override {
        size_t scLen = sizeof( start_code );
        if ( !firstFrameWritten ) {
            // 填写起始码
            memcpy( recvBuf - scLen, start_code, scLen );
            // 防止RTSP源不送SPS/PPS
            unsigned numSPropRecords;
            SPropRecord *sPropRecords = parseSPropParameterSets( sPropParameterSetsStr, numSPropRecords );
            for ( unsigned i = 0; i &lt; numSPropRecords; ++i ) {
                unsigned int propLen = sPropRecords[ i ].sPropLength;
                size_t bufLen = propLen + scLen;
                unsigned char buf[bufLen];
                memcpy( buf, start_code, scLen );
                memcpy( buf + scLen, sPropRecords[ i ].sPropBytes, propLen );
                wsClient-&gt;sendBytes( buf, bufLen );
#ifdef _SAVE_H264_SEQ
                fwrite( buf, sizeof( unsigned char ), bufLen, os );
#endif
            }
            firstFrameWritten = true;
        }
#ifdef _SAVE_H264_SEQ
        fwrite( recvBuf - scLen, sizeof( unsigned char ), frameSize + scLen, os );
#endif
        unsigned naluHead = recvBuf[ 0 ];
        unsigned nri = naluHead &gt;&gt; 5;
        unsigned f = nri &gt;&gt; 2;
        unsigned type = naluHead &amp; 0b00011111;
        wsClient-&gt;sendBytes( recvBuf - scLen, frameSize + scLen );
        LOGGER-&gt;trace( "NALU info: nri {} type {}", nri, type );
        SinkBase::afterGettingFrame( frameSize, numTruncatedBytes, presentationTime );
    }
};

class H264RTSPClient : public RTSPClientBase {
private:
    VideoSink *videoSink;
public:
    H264RTSPClient( UsageEnvironment &amp;env, const char *rtspURL, VideoSink *videoSink ) :
        RTSPClientBase( env, rtspURL ), videoSink( videoSink ) {}

protected:
    // 测试用的摄像头（RTSP源）仅仅有一个子会话，因此这里简化了实现：
    bool acceptSubSession( const char *mediumName, const char *codec ) override {
        return true;
    }

    MediaSink *createSink( const char *mediumName, const char *codec, MediaSubsession *subSession ) override {
        videoSink-&gt;onMediaSubsessionOpened( subSession );
        return videoSink;
    }
};

int main() {
    spdlog::set_pattern( "%Y-%m-%d %H:%M:%S.%e [%l] [%n] %v" );
    spdlog::set_level( spdlog::level::trace );

    WebSocketClient *wsClient;
    wsClient = new WebSocketClient( "ws://192.168.0.89:9090/h264src" );
    wsClient-&gt;connect();
    sleep( 3 ); // 等待WebSocket连接建立
    wsClient-&gt;sendText( "ch1" );
    TaskScheduler *scheduler = BasicTaskScheduler::createNew();
    BasicUsageEnvironment *env = BasicUsageEnvironment::createNew( *scheduler );
    VideoSink *sink = new VideoSink( *env, 1024 * 1024, wsClient );
    H264RTSPClient *client = new H264RTSPClient( *env, "rtsp://admin:kingsmart123@192.168.0.196:554/ch1/sub/av_stream", sink );
    client-&gt;start();
    return 0;
}</pre>
<div class="blog_h2"><span class="graybg">Java部分</span></div>
<p>这部分实现了NALU转发功能，基于Spring Boot实现。</p>
<div class="blog_h3"><span class="graybg">主程序</span></div>
<pre class="crayon-plain-tag">package cc.gmem.study.kurento;

import org.kurento.client.KurentoClient;
import org.kurento.client.KurentoClientBuilder;
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.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.scheduling.annotation.EnableScheduling;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.*;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import sun.security.acl.PrincipalImpl;

import java.security.Principal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@SpringBootApplication
@EnableWebSocket
@EnableWebSocketMessageBroker
@EnableScheduling
public class VideoSurveillanceApp extends AbstractWebSocketMessageBrokerConfigurer implements WebSocketConfigurer {

    private static final Logger LOGGER = LoggerFactory.getLogger( VideoSurveillanceApp.class );

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        // WebSocket消息缓冲区大小，如果客户端发来的消息较大，需要按需调整
        container.setMaxTextMessageBufferSize( 1024 * 1024 );
        container.setMaxBinaryMessageBufferSize( 1024 * 1024 );
        return container;
    }

    @Override
    public void registerWebSocketHandlers( WebSocketHandlerRegistry registry ) {
        registry.addHandler( h264FrameSinkHandler(), "/h264sink" );
        registry.addHandler( h264FrameSrcHandler(), "/h264src" );
    }

    @Bean
    public WebSocketHandler h264FrameSrcHandler() {
        return new H264FrameSrcHandler();
    }

    @Bean
    public WebSocketHandler h264FrameSinkHandler() {
        return new H264FrameSinkHandler();
    }

    public static void main( String[] args ) {
        new SpringApplication( VideoSurveillanceApp.class ).run( args );
    }

}</pre>
<div class="blog_h3"><span class="graybg">H264FrameSrcHandler</span></div>
<p>此Bean接受C++程序的NALU推送：</p>
<pre class="crayon-plain-tag">package cc.gmem.study.kurento;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.AbstractWebSocketHandler;

import javax.inject.Inject;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


public class H264FrameSrcHandler extends AbstractWebSocketHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger( H264FrameSrcHandler.class );

    private Map&lt;String, String&gt; sessionIdToChannel = new ConcurrentHashMap&lt;&gt;();

    @Inject
    private H264FrameSinkHandler sinkHandler;

    public void afterConnectionEstablished( WebSocketSession session ) throws Exception {
        LOGGER.debug( "{} connected.", session.getRemoteAddress() );
    }

    @Override
    protected void handleBinaryMessage( WebSocketSession session, BinaryMessage message ) throws Exception {
        ByteBuffer payload = message.getPayload();
        StringBuilder hex = new StringBuilder();
        byte[] pa = payload.array();
        int len = 16;
        if ( pa.length &lt; 16 ) len = pa.length;
        for ( byte i = 0; i &lt; len; i++ ) {
            hex.append( String.format( "%02x ",Byte.toUnsignedInt( pa[i] )  ) );
        }
        LOGGER.debug( "Received binary message {} bytes: {}...", payload.array().length, hex );
        String chnl = sessionIdToChannel.get( session.getId() );
        if ( chnl != null ) sinkHandler.broadcast( chnl, payload );
    }

    @Override
    protected void handleTextMessage( WebSocketSession session, TextMessage message ) throws Exception {
        String payload = message.getPayload();
        sessionIdToChannel.put( session.getId(), payload );
        LOGGER.debug( "Received text message: {}", payload );
    }
}</pre>
<div class="blog_h3"><span class="graybg">H264FrameSinkHandler</span></div>
<p>此Bean向Web客户端广播NALU：</p>
<pre class="crayon-plain-tag">package cc.gmem.study.kurento;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.mutable.MutableInt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import javax.inject.Inject;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class H264FrameSinkHandler extends TextWebSocketHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger( H264FrameSinkHandler.class );

    public static final String ACTION_INIT = "init";

    private static final String ACTION_INIT_RESP = "initresp";

    public static final String ACTION_PLAY = "play";

    public static final String ACTION_STOP = "stop";

    public static final String KEY_ACTION = "action";


    @Inject
    private ObjectMapper om;

    private Map&lt;String, List&lt;WebSocketSession&gt;&gt; chnlToSessions = new ConcurrentHashMap&lt;&gt;();

    @Override
    protected void handleTextMessage( WebSocketSession session, TextMessage message ) throws Exception {
        String client = session.getId() + '@' + session.getRemoteAddress();
        Map req = om.readValue( message.getPayload(), Map.class );
        Map resp = new LinkedHashMap();
        Object action = req.get( KEY_ACTION );
        if ( ACTION_INIT.equals( action ) ) {
            String channel = (String) req.get( "channel" );
            LOGGER.debug( "{} request to subscribe channel {}", client, channel );
            addPushTarget( channel, session );

            resp.put( KEY_ACTION, ACTION_INIT_RESP );
            resp.put( "width", 352 );
            resp.put( "height", 288 );
            session.sendMessage( new TextMessage( om.writeValueAsString( resp ) ) );
        } else if ( ACTION_PLAY.equals( action ) ) {
            LOGGER.debug( "{} request to receive nalu push", session.getRemoteAddress(), client );
            session.getAttributes().put( ACTION_PLAY, true );
        }
    }

    private synchronized void addPushTarget( String channel, WebSocketSession session ) {
        List&lt;WebSocketSession&gt; sessions = chnlToSessions.get( channel );
        if ( sessions == null ) {
            sessions = new ArrayList&lt;&gt;();
            chnlToSessions.put( channel, sessions );
        }
        sessions.add( session );
    }

    public synchronized void broadcast( String chnl, ByteBuffer payload ) {
        List&lt;WebSocketSession&gt; sessions = chnlToSessions.get( chnl );
        if ( sessions == null ) return;
        sessions.forEach( sess -&gt; {
            try {
                if ( sess.isOpen() &amp;&amp; Boolean.TRUE.equals( sess.getAttributes().get( ACTION_PLAY ) ) ) {
                    sess.sendMessage( new BinaryMessage( payload ) );
                }
            } catch ( Exception e ) {
                LOGGER.error( e.getMessage(), e );
            }
        } );
    }

    @Scheduled( fixedRate = 10000 )
    public synchronized void cleanup() {
        final MutableInt counter = new MutableInt( 0 );
        chnlToSessions.values().forEach( sessions -&gt; {
            Iterator&lt;WebSocketSession&gt; it = sessions.listIterator();
            while ( it.hasNext() ) {
                if ( !it.next().isOpen() ) {
                    it.remove();
                    counter.increment();
                }
            }
        } );
        if ( counter.intValue() &gt; 0 ) LOGGER.debug( "Remove {} invalid websocket session.", counter );
    }
}</pre>
<div class="blog_h2"><span class="graybg">Web部分</span></div>
<p>我们对http-live-player进行了简单的修改，主要是修改其通信方式以配合上述WebSocket服务器。核心代码没有变动，因此这里不张贴其代码。</p>
<div class="blog_h3"><span class="graybg">客户端代码</span></div>
<pre class="crayon-plain-tag">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;title&gt;Broadway Video Surveillance&lt;/title&gt;
    &lt;script src="js/broadway/http-live-player.js"&gt;&lt;/script&gt;
    &lt;link rel="stylesheet" href="style.css"&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class="nvbar"&gt;
    &lt;div class="title"&gt;基于Broadway+WebSocket的视频监控示例&lt;/div&gt;
    &lt;div class="subtitle"&gt;http://192.168.0.89:9090/broadway.html&lt;/div&gt;
&lt;/div&gt;
&lt;div class="videos-wrapper"&gt;
    &lt;div id="videos"&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;script type="text/javascript"&gt;
    var videos = document.getElementById( 'videos' );
    for ( var i = 0; i &lt; 9; i++ ) {
        var canvas = document.createElement( "canvas" );
        videos.appendChild( canvas );
        var player = new WSAvcPlayer( canvas, "webgl", 'ch1', true );
        player.connect( "ws://" + document.location.host + "/h264sink" );
    }
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</pre>
<div class="blog_h3"><span class="graybg">效果截图</span></div>
<p>下面的截图是开了九画面的视频监控，使用的是子码流，在测试机器上CPU压力不大。</p>
<p><img class="aligncenter size-full wp-image-16511" src="https://blog.gmem.cc/wp-content/uploads/2017/10/html5-h264.png" alt="html5-h264" width="798" height="697" /></p>
<p>注意：如果Broadway来不及解码，http-live-player会把缓冲区中的所有NALU全部丢弃，这可能导致暂时的花屏。选择适当的帧率、码率、画幅可以尽量避免这种情况的发生。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/html5-vs-with-broadway">基于Broadway的HTML5视频监控</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/html5-vs-with-broadway/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>基于Kurento搭建WebRTC服务器</title>
		<link>https://blog.gmem.cc/webrtc-server-basedon-kurento</link>
		<comments>https://blog.gmem.cc/webrtc-server-basedon-kurento#comments</comments>
		<pubDate>Thu, 31 Aug 2017 09:08:42 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[Graphic]]></category>
		<category><![CDATA[Java]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[Work]]></category>
		<category><![CDATA[Multimedia]]></category>
		<category><![CDATA[WebRTC]]></category>
		<category><![CDATA[视频监控]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=15701</guid>
		<description><![CDATA[<p>基础 Kurento是一个WebRTC媒体服务器，同时提供了一系列的客户端API，可以简化供浏览器、移动平台使用的视频类应用程序的开发。Kurento支持： 群组通信（group communications） 媒体流的转码（transcoding）、录制（recording）、广播（broadcasting）、路由（routing） 高级媒体处理特性，包括：机器视觉（CV）、视频索引、增强现实（AR）、语音分析 Kurento的模块化架构使其与第三方媒体处理算法 —— 语音识别、人脸识别 —— 很容易集成。 架构 和大部分多媒体通信技术一样，Kurento应用的整体架构包含两个层（layer）或者叫平面（plane）： 信号平面（Signaling Plane）：负责通信的管理，例如媒体协商、QoS、呼叫建立、身份验证等 媒体平面（Media Plane）：负责媒体传输、编解码等 典型Kurento应用的整体架构如下图： 分层视角 按分层的方式来划分，Kurento应用可以分为三层（类似于典型的Web应用）： 展现层 —— <a class="read-more" href="https://blog.gmem.cc/webrtc-server-basedon-kurento">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/webrtc-server-basedon-kurento">基于Kurento搭建WebRTC服务器</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">基础</span></div>
<p>Kurento是一个WebRTC媒体服务器，同时提供了一系列的客户端API，可以简化供浏览器、移动平台使用的视频类应用程序的开发。Kurento支持：</p>
<ol>
<li>群组通信（group communications）</li>
<li>媒体流的转码（transcoding）、录制（recording）、广播（broadcasting）、路由（routing）</li>
<li>高级媒体处理特性，包括：机器视觉（CV）、视频索引、增强现实（AR）、语音分析</li>
</ol>
<p>Kurento的模块化架构使其与第三方媒体处理算法 —— 语音识别、人脸识别 —— 很容易集成。</p>
<div class="blog_h2"><span class="graybg">架构</span></div>
<p>和大部分多媒体通信技术一样，Kurento应用的整体架构包含两个层（layer）或者叫平面（plane）：</p>
<ol>
<li>信号平面（Signaling Plane）：负责通信的管理，例如媒体协商、QoS、呼叫建立、身份验证等</li>
<li>媒体平面（Media Plane）：负责媒体传输、编解码等</li>
</ol>
<p>典型Kurento应用的整体架构如下图：</p>
<p><img class="aligncenter size-full wp-image-15744" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurentoapp-architecture.png" alt="kurentoapp-architecture" width="670" height="495" /></p>
<div class="blog_h3"><span class="graybg">分层视角</span></div>
<p>按分层的方式来划分，Kurento应用可以分为三层（类似于典型的Web应用）：</p>
<ol>
<li>展现层 —— 浏览器、移动应用、其它媒体源等应用客户端：
<ol>
<li> 基于任意协议和应用逻辑层通信，发起信号处理</li>
<li> 基于RTP/HTTP/WebRTC协议和KMS通信：
<ol>
<li>通过KMS的输入端点，传输媒体流到KMS</li>
<li>通过KMS的输出端点，从KMS获得媒体流</li>
</ol>
</li>
</ol>
</li>
<li>应用逻辑层——应用服务器负责信号平面：
<ol>
<li>基于WebSocket/HTTP/REST/SIP等方式和应用客户端通信，进行信号处理</li>
<li>内嵌Kurento Client，基于Kurento Protocol与KMS通信，管理媒体元素/媒体管线</li>
</ol>
</li>
<li>服务层——KMS负责媒体平面，可以对输入流进行各种处理，并产生输出流</li>
</ol>
<div class="blog_h3"><span class="graybg">层之间的交互</span></div>
<p>媒体协商（信号处理）阶段：</p>
<ol>
<li>客户端首先向应服务器请求某种媒体特性（例如请求一个九画面视频监控流、请求发布自己的SDP）。这块WebRTC没有规定，可以基于任何协议（HTTP/WS/SIP）实现</li>
<li>应用服务器接收到请求后，执行特定的服务器端逻辑，例如AAA（认证授权审计）、<span style="color: #404040;">CDR生成等</span></li>
<li>应用服务器处理请求，并命令KMS实例化适当的媒体元素、构建媒体流（例如从多个RTSP源混合出九画面）</li>
<li>媒体流构建完毕后，KMS应答应用服务器，后者应答客户端，告知其如何获取媒体服务</li>
</ol>
<p>媒体交换阶段：</p>
<ol>
<li>客户端利用协商阶段收集的信息，向KMS发起请求（例如向目标端口发起UDP请求，获取九画面视频监控流）</li>
</ol>
<p>下图是交互的序列示意，注意先后顺序：</p>
<p><img class="aligncenter size-full wp-image-15749" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurentoapp-generic_interactions.png" alt="kurentoapp-generic_interactions" width="670" height="439" /></p>
<div class="blog_h3"><span class="graybg">WebRTC应用的例子</span></div>
<p style="text-align: left;">Kurento允许基于WebRTC建立浏览器和KMS之间的实时多媒体会话：</p>
<ol>
<li>客户端基于SDP来发布自己的媒体特性，请求发送给应用服务器</li>
<li>应用服务器根据SDP来创建合适的WebRTC端点，并请求KMS生成一个响应SDP</li>
<li>应用服务器获得响应SDP后，将其返回给客户端</li>
<li>由于双方都知道对方的SDP了，客户端和KMS可以进行媒体交换了</li>
</ol>
<p>下图是交互的序列示意：</p>
<p style="text-align: left;"><img class="aligncenter size-full wp-image-15761" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurento-webrtc-session.png" alt="kurento-webrtc-session" width="670" height="439" /></p>
<p style="text-align: left;">Kurento也可以作为一个媒体代理，让浏览器之间建立直接的媒体交换。交互序列仍然如上图，仅仅是KMS返回的SDP不同</p>
<div class="blog_h2"><span class="graybg">媒体服务器</span></div>
<p>WebRTC让浏览器能够进行实时的点对点通信（在没有服务器的情况下）。但是要想实现群组通信、媒体流录制、媒体广播、转码等高级特性，没有媒体服务器是很难实现的。</p>
<p>Kurento的核心是一个媒体服务器（Kurento Media Server，KMS），负责媒体的传输、处理、加载、录制，主要基于 GStreamer实现。此媒体服务器的特性包括：</p>
<ol>
<li>网络流协议处理，包括HTTP、RTP、WebRTC</li>
<li>支持媒体混合（mixing）、路由和分发的群组通信（MCU、SFU功能）</li>
<li>对机器视觉和增强现实过滤器的一般性支持</li>
<li>媒体存储支持，支持对WebM、MP4进行录像操作，可以播放任何GStreamer支持的视频格式</li>
<li>对于GStreamer支持的编码格式，可以进行任意的转码，例如VP8, H.264, H.263, AMR, OPUS, Speex, G.711</li>
</ol>
<div class="blog_h2"><span class="graybg">模块</span></div>
<p>KMS基于模块化的设计，模块主要分为三类：</p>
<ol>
<li>核心（kms-core）</li>
<li>媒体元素（kms-elements）</li>
<li>过滤器（kms-filters）</li>
<li>其它增强KMS的模块，例如kms-crowddetector, kms-pointerdetector, kms-chroma, kms-platedetector</li>
</ol>
<p>KMS允许用户扩展自己的模块。</p>
<div class="blog_h2"><span class="graybg">协议</span></div>
<p>Kurento Protocol是一个网络协议，通过WebSocket暴露KMS的特性。</p>
<p>Kurento API是对上述协议的OO封装，通过此API能够创建媒体元素和管线。Kurento提供了API的Java、JavaScript绑定。</p>
<div class="blog_h2"><span class="graybg">客户端</span></div>
<p>Kurento提供了Java、JavaScript（包括浏览器和Node.js）的客户端库，通过这些库你可以控制媒体服务器。对于其它编程语言，可以使用 Kurento Protocol协议（基于WebSocket/JSON-RPC）。</p>
<p>Kurento客户端API基于所谓媒体元素（Media Element）的概念。一个每天元素持有一种特定的媒体特性。例如：</p>
<ol>
<li>媒体元素WebRtcEndpoint的特性是，接收WebRTC媒体流</li>
<li>媒体元素RecorderEndpoint的特性是，将接收到的媒体流录制到文件系统</li>
<li>媒体元素FaceOverlayFilter则能够检测人脸，在其上方显示一个特定的图像</li>
</ol>
<p>开箱即用的媒体元素如下图：</p>
<p><img class="aligncenter size-full wp-image-15714" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurento-basic-toolbox.png" alt="kurento-basic-toolbox" width="500" height="324" /></p>
<p>从开发者角度来说，操控媒体元素就好像搭积木。 你只需要按照期望的拓扑结构把它们连接起来就可以了。一系列连接起来的媒体元素称为媒体管线（Media Pipeline）。只有一个管线内部的媒体元素才能相互通信</p>
<p>当创建管道时，开发者需要明确希望使用到的特性，以及媒体连接（connectivity） —— 产生媒体的元素和消费媒体的元素之间的连接：</p>
<pre class="crayon-plain-tag">sourceMediaElement.connect(sinkMediaElement);
// 例如：客户端接收WebRTC流并录制到媒体服务器的文件系统
webRtcEndpoint.connect(recorderEndpoint);</pre>
<div class="blog_h3"><span class="graybg">Web客户端</span></div>
<p>为了简化浏览器客户端的WebRTC流处理，Kurento提供了工具WebRtcPeer，你仍然可以使用WebRTC的标准API，以及连接到WebRtcEndpoint。</p>
<div class="blog_h1"><span class="graybg">安装配置</span></div>
<div class="blog_h2"><span class="graybg">安装</span></div>
<p>你可以在64位Ubuntu 14.04 LTS上安装KMS：</p>
<pre class="crayon-plain-tag">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的那个方案</pre>
<p>要启动或者停止KMS服务，执行下面的命令：</p>
<pre class="crayon-plain-tag"># 启动服务
sudo service kurento-media-server-6.0 start
# 停止服务
sudo service kurento-media-server-6.0 stop</pre>
<div class="blog_h3"><span class="graybg">兼容性</span></div>
<p>Trickle ICE是对ICE的扩展，它允许ICE代理（KMS、客户端）增量的收发candidates而不是交换完整的candidate列表。</p>
<p>由于使用了Trickle ICE协议， 目前的6.0版本的KMS和5.1-版本不兼容，你需要卸载老版本后重新安装：</p>
<pre class="crayon-plain-tag">sudo apt-get remove kurento-media-server
sudo apt-get purge kurento-media-server
sudo apt-get autoremove</pre>
<p>注意sources.list文件和sources.list.d下的文件中，对kurento的引用也要删除。</p>
<div class="blog_h3"><span class="graybg">容器化</span></div>
<p>在Ubuntu:14.04容器中安装后，关闭容器，提交为镜像：</p>
<pre class="crayon-plain-tag">docker commit kurento docker.gmem.cc/kurento:base</pre>
<p>新建一个Docker项目：</p>
<pre class="crayon-plain-tag">FROM docker.gmem.cc/kurento:base

ADD /fs /
RUN chmod +x /entrypoint.sh

CMD ["/entrypoint.sh"] </pre>
<p><a id="entrypoint"></a>入口点脚本：</p>
<pre class="crayon-plain-tag">#!/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 &gt; /dev/null
logfile=`find . -name "*pid$kmspid.log" | head -n 1`

# 持续输出当前日志的内容，确保容器不退出
tail -f $logfile &amp;
pid=$!
# 捕获到信号的时候，下面的命令退出 —— 等待被中断
wait $pid

# 信号处理完毕后，执行下面的命令，如果tail这个子进程已经终止，则wait会立即退出
# 如果子进程正在处理TERM信号，则等待其处理完毕后，wait退出
# 如果没有这个double wait，则子进程有可能成为僵尸，因为没有父进程实际完成wait系统调用
wait $pid</pre>
<p>构建新镜像：<pre class="crayon-plain-tag">docker build --force-rm -t docker.gmem.cc/kurento .</pre> </p>
<p>创建基于新镜像容器： </p>
<pre class="crayon-plain-tag">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</pre>
<p>启动容器：</p>
<pre class="crayon-plain-tag">docker start -i kurento</pre>
<div class="blog_h2"><span class="graybg">构建</span></div>
<p>要自己构建Kurento，可以参考本节的操作步骤。本节记录的操作步骤是在Ubuntu 14.04 TLS上执行的。</p>
<div class="blog_h3"><span class="graybg">构建OpenCV</span></div>
<p>kms-filters依赖于此库：</p>
<pre class="crayon-plain-tag">pushd /home/alex/CPP/lib  &gt; /dev/null
mkdir opencv
pushd opencv &gt; /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 &gt; /dev/null
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/home/alex/CPP/lib/opencv/2.4.13 ..
make &amp;&amp; make install
popd &amp;&amp; popd</pre>
<div class="blog_h3"><span class="graybg">构建kurento组件</span></div>
<pre class="crayon-plain-tag"># 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 &gt; /dev/null


# 构建kms-cmake-utils
git clone https://github.com/Kurento/kms-cmake-utils.git
pushd kms-cmake-utils  &gt; /dev/null
mkdir build
pushd build &gt; /dev/null
cmake $CMAKE_OPTS ..
make &amp;&amp; make install
popd &amp;&amp; popd


# 构建kurento-module-creator
git clone https://github.com/Kurento/kurento-module-creator.git
pushd kurento-module-creator &gt; /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  &gt; /dev/null
mkdir build
pushd build &gt; /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 &amp;&amp; make install
popd &amp;&amp; popd

git clone https://github.com/Kurento/kms-jsonrpc.git
pushd kms-jsonrpc &gt; /dev/null
mkdir build
pushd build &gt; /dev/null
# 下一步会报错 package 'kmsjsoncpp&gt;=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 &amp;&amp; make install
popd &amp;&amp; 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 &gt; /dev/null
mkdir build
pushd build &gt; /dev/null
cmake $CMAKE_OPTS ..
# 构建时又找不到BOOST头文件
export CPATH=$CPATH:/home/alex/CPP/lib/boost/1.65.1/include
make &amp;&amp; make install
popd &amp;&amp; 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 &gt; /dev/null
./autogen.sh
./configure --prefix=$KURENTO_HOME
make &amp;&amp; make install
popd

sudo apt-get install libnice-dev

git clone https://github.com/Kurento/kms-elements.git
pushd kms-elements &gt; /dev/null
mkdir build
pushd build &gt; /dev/null
cmake $CMAKE_OPTS ..
export CPATH=$CPATH:$KURENTO_HOME/include/gstreamer-1.5
make &amp;&amp; make install
popd &amp;&amp; popd


# 构建kms-filters
git clone https://github.com/Kurento/kms-filters.git
pushd kms-filters &gt; /dev/null
mkdir build
pushd build &gt; /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 &amp;&amp; make install
popd &amp;&amp; popd


# 构建 kurento-media-server
git clone https://github.com/Kurento/kurento-media-server.git
pushd kurento-media-server &gt; /dev/null
mkdir build
pushd build &gt; /dev/null
cmake $CMAKE_OPTS ..
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/alex/CPP/lib/kurento/lib/x86_64-linux-gnu
make &amp;&amp; make install
popd &amp;&amp; popd</pre>
<p>运行，要运行自己构建的KMS，参考如下脚本：</p>
<pre class="crayon-plain-tag">#!/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</pre>
<div class="blog_h2"><span class="graybg">配置</span></div>
<div class="blog_h3"><span class="graybg">配置文件</span></div>
<p>KMS的主配置文件位于/etc/kurento/kurento.conf.json，内容如下：</p>
<pre class="crayon-plain-tag">{
  "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
      }
    }
  }
}</pre>
<p>此外还有以下配置文件：</p>
<table class="full-width fixed-word-wrap">
<tbody>
<tr>
<td>媒体元素的一般性参数：/etc/kurento/modules/kurento/MediaElement.conf.json</td>
</tr>
<tr>
<td>
<p>SDP端点（WebRtcEndpoint、RtpEndpoint）的音视频参数</p>
<p>/etc/kurento/modules/kurento/SdpEndpoint.conf.json</p>
</td>
</tr>
<tr>
<td>WebRtcEndpoint专有参数：/etc/kurento/modules/kurento/WebRtcEndpoint.conf.json</td>
</tr>
<tr>
<td>HttpEndpoint专有参数：/etc/kurento/modules/kurento/HttpEndpoint.conf.json</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">STUN支持</span></div>
<p>如果KMS位于NAT设备后面，你需要使用STUN或者TURN以便实现NAT穿透。大部分情况下STUN足够，在对称NAT的情况下才需要使用TURN。</p>
<p>要启用STUN支持，修改配置文件： </p>
<pre class="crayon-plain-tag">; 解除注释：
stunServerAddress=&lt;serverAddress&gt;
stunServerPort=&lt;serverPort&gt;
; 公网有很多免费的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</pre>
<div class="blog_h3"><span class="graybg">TURN支持</span></div>
<p>要启用TURN支持，解除注释：</p>
<pre class="crayon-plain-tag">turnURL=user:password@address:port</pre>
<p>一个开源的TURN实现是<a href="https://github.com/coturn/coturn">coturn</a></p>
<div class="blog_h3"><span class="graybg">日志</span></div>
<p>KMS的日志默认存放在/var/log/kurento-media-server/目录下：</p>
<ol>
<li>media-server_&lt;timestamp&gt;.&lt;log_number&gt;.&lt;kms_pid&gt;.log为本次运行的KMS日志</li>
<li>media-server_error.log为第三方错误日志</li>
<li>logs子目录存放历史日志</li>
</ol>
<div class="blog_h1"><span class="graybg"><span class="graybg">Kurento API</span></span></div>
<p>Kurento提供了Java/JavaScript的API，对于其它编程语言，目前需要通过WebSocket/JSON-RPC使用Kurento Protocol。 </p>
<p>本章仅仅进行概念上的阐述，如果需要了解针对具体语言的API，请参阅官方文档：</p>
<ol>
<li><a href="http://doc-kurento.readthedocs.io/en/stable/_static/langdoc/javadoc/index.html">kurento-client-java</a>：Java客户端</li>
<li><a href="http://doc-kurento.readthedocs.io/en/stable/_static/langdoc/jsdoc/kurento-client-js/index.html">kurento-client-js</a>：JavaScript客户端</li>
<li><a href="http://doc-kurento.readthedocs.io/en/stable/_static/langdoc/jsdoc/kurento-utils-js/index.html">kurento-utils-js</a>：用于简化WebRTC应用开发的JavaScript工具</li>
</ol>
<div class="blog_h2"><span class="graybg">整体结构</span></div>
<p>Kurento的主要类型的类图如下，可以看到MediaObject是所有类型的根，并且实现了组合模式：</p>
<p><img class="aligncenter size-full wp-image-15773" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurento-classes.png" alt="kurento-classes" width="521" height="285" /></p>
<div class="blog_h2"><span class="graybg">媒体元素/管线</span></div>
<p>媒体元素和媒体管线是最核心的API。</p>
<div class="blog_h3"><span class="graybg">媒体元素</span></div>
<p>MediaElement是媒体流中，执行特定动作的功能单元。它让媒体特性对于应用开发者表现为自包含的黑盒，这些开发者不需要了解底层细节。</p>
<p>MediaElement可以通过mediaSrcs从其它媒体元素接收媒体，或者通过mediaSinks将媒体发送给其它媒体元素。</p>
<p>根据功能的不同，MediaElement可以分为：</p>
<ol>
<li>输入媒体元素：支持接收媒体，并将媒体注入到管线中。这类媒体元素有多种，实现从文件、网络、摄像头等来源读取媒体流</li>
<li>过滤器：能够转换、分析媒体流，实现混合、AR之类的功能</li>
<li>HubPort：Hub负责管理管线中的多个媒体流。每个Hub有多个HubPort，这些HubPort连接其它媒体元素</li>
<li>输出媒体元素：支持输出媒体，将媒体流带出管线。实现录像、在屏幕上播放、发送到网络等功能</li>
</ol>
<p>MediaElement常常由Endpoint实现，后者可能同时作为输入、输出元素。</p>
<div class="blog_h3"><span class="graybg">媒体管线</span></div>
<p>MediaPipeline是MediaElement构成的链条。链条可以有多个作为入口点的输入元素。由一个元素生成的输出流（SRC）可能输入到1-N个元素的输入流（SINK）：</p>
<p><img class="aligncenter size-full wp-image-15776" src="https://blog.gmem.cc/wp-content/uploads/2017/08/pipeline-src-sink.png" alt="pipeline-src-sink" width="400" height="141" /></p>
<div class="blog_h2"><span class="graybg">端点</span></div>
<p>端点是MediaElement的一种实现，能够输入、输出媒体流。端点类层次的类图如下：</p>
<p><img class="aligncenter size-full wp-image-15779" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurento-endpoints.png" alt="kurento-endpoints" width="490" height="428" /></p>
<p>这些端点的功能简述如下表：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">端点</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>WebRtcEndpoint</td>
<td>输入输出端点（能够接受外部输入、也能够输出到外部），实现WebRTC协议</td>
</tr>
<tr>
<td>RtpEndpoint</td>
<td>输入输出端点，基于SDP进行媒体协商，基于RTP进行流发送</td>
</tr>
<tr>
<td>HttpPostEndpoint</td>
<td>输入端点，支持类似于HTTP文件上传那样的POST请求</td>
</tr>
<tr>
<td>PlayerEndpoint</td>
<td>输入端点，支持从文件系统、HTTP URL、RTSP URL接收内容，并将其注入到媒体管线中</td>
</tr>
<tr>
<td>RecorderEndpoint </td>
<td> 输出端点，以可靠的方式存储媒体内容到文件系统。用法示例：<br />
<pre class="crayon-plain-tag">recorder = new RecorderEndpoint.Builder(pipeline, "录像存储路径").build();
webrtcEndpoint.connect(recorder);</pre>
</td>
</tr>
</tbody>
</table>
<p>/home/alex/CPP/lib/kurento/src/kurento-media-server</p>
<p>关于端点，要注意：</p>
<ol>
<li>这些<span style="background-color: #c0c0c0;">端点都是在KMS中运行的</span>！尽管你会通过Java/Node的客户端，在应用服务器上操控端点，但是实质上都是基于Kurento协议向KMS发起远程调用</li>
<li>端点可能具有SRC、SINK端子，分别用于发送媒体流到其它端点、接受其它端点的发来的媒体流。SRC、SINK是媒体管线内部概念</li>
<li>端点可能对外部系统具有接收、发送媒体流的功能（但不叫SRC/SINK），例如WebRtcEndpoint。接收到的媒体流可以通过SRC发送给其它端点，其它端点发送到SINK的媒体流可以转发到外部系统</li>
<li>端点自己的SRC可以连接到自己的SINK</li>
</ol>
<div class="blog_h3"><span class="graybg">WebRtcEndpoint</span></div>
<p>代表一个运行在KMS中的WebRTC端点，是这类端点的控制接口。WebRTC端点可以和浏览器中的WebRTC客户端交互。例如<a href="#loopback">环回视频流</a>的那个实例，其媒体流向图如下：</p>
<p><img class="aligncenter size-full wp-image-15967" src="https://blog.gmem.cc/wp-content/uploads/2017/08/kurento-loopback.png" alt="kurento-loopback" width="600" height="319" /></p>
<p>说明如下：</p>
<ol>
<li>摄像头出来视频流，一方面在本地浏览器上渲染</li>
<li>另外一方面，发送给KMS中的WebRTCEndpoint端点</li>
<li>上一步的媒体流，到达SRC端子，进而发给自己的SINK端子（环回）</li>
<li>SINK端子的媒体流发回给浏览器</li>
<li>浏览器在另外一个video元素中渲染视频流</li>
</ol>
<p>WebRTC端点是P2P的WebRTC通信的一端，<span style="background-color: #c0c0c0;">另一端可以是使用RTCPeerConnection接口的浏览器、Native的WebRTC应用程序、甚至是另一个KMS服务器</span>。</p>
<p>为了建立WebRTC通信，两端必须进行SDP协商，其中一方作为邀请者（Offerer）另外一方作为应答者（Offeree），WebRTC端点可以作为两种角色之一。</p>
<p>当作为邀请者时：</p>
<ol>
<li>KMS客户端调用generateOffer()方法后，KMS生成一个SDP offer，此Offer返回给KMS客户端（应用服务器），再被转发给浏览器</li>
<li>浏览器处理上述Offer，并产生一个应答，应答传递给KMS客户端</li>
<li>后者调用processAnswer()导致应答转发给KMS</li>
</ol>
<p>当作为应答者时：</p>
<ol>
<li>浏览器生成一个SDP offer，发送到KMS客户端</li>
<li>KMS客户端调用processOffer()，SDP被转发给KMS，KMS生成应答，发送给KMS客户端</li>
<li>KMS客户端把应答转发给浏览器处理</li>
</ol>
<p>SDP独立于ICE候选发送。Kurento使用优化了的ICE收发机制 ——  Trickle ICE。两端分别、独立的执行收集ICE候选：</p>
<ol>
<li>浏览器中候选会自动收集，你可以使用onicecandidate回调接收通知。此事件常常比SDP处理发生的更快</li>
<li>KMS必须依赖于客户端调用gatherCandidates()，并在此调用之前注册IceCandidateFound的监听器</li>
</ol>
<p>KMS、浏览器每收集到一个ICE候选，就（以KMS客户端也就是应用服务器）为中介，发送给对方。接收到对方的ICE候选后，双方就会开始尝试建立双向连接。</p>
<p>需要注意WebRTC信号处理的异步性，假设你希望录制WebRTC端点的视频，在媒体流实际发送之前就执行录制是没有意义的。要感知WebRTC端点的状态，你需要监听端点的事件：</p>
<ol>
<li>IceComponentStateChange，在WebRTC点对点连接性发生变化后立即发布。这个事件仅仅能用于检测底层的连接性，处于CONNECTED 状态不意味着媒体流就已经开始传输。连接性状态包括（RFC5245定义了它们之间的状态转换图）：
<ol>
<li>DISCONNECTED  没有任何被调度的活动</li>
<li>GATHERING 开始收集本地（KMS服务器）的ICE候选</li>
<li>/home/alex/CPP/lib/kurento/src/kurento-media-serverCONNECTING  尝试创建连接，这在接收到对方的ICE候选后触发</li>
<li>CONNECTED  至少一个有效的ICE候选对出现，导致双向连接成功</li>
<li>READY  ICE结束，候选对选择完成</li>
<li>FAILED  连接性检查已经完毕，但是媒体流连接没有建立</li>
</ol>
</li>
<li>IceCandidateFound，一旦新的ICE候选可用即触发，这些候选必须被发送给对方</li>
<li>IceGatheringDone，所有ICE候选都被收集完毕后触发</li>
<li>NewCandidatePairSelected，当新的ICE候选对（本地、远程）可用时触发，当媒体会话已经进行后，此事件仍然可以触发 —— 一个更高优先级的ICE候选对被发现时</li>
<li>DataChannelOpen，数据通道打开时</li>
<li>DataChannelClose，数据通道关闭后</li>
</ol>
<p>流控制、拥塞管理是WebRTC最重要的一项功能。WebRTC连接总是以一个较低的带宽开始，慢慢的加大到最大可用带宽。WebRTC 端点如果服务多个外部连接，那么它们将共享一个码流质量，这意味着一个新的外部连接接入后，现有连接的码流质量会下降（因为要从较低带宽开始）。</p>
<p>默认的带宽范围取值在100kbps-500kbps之间，可以单独设置SRC/SINK、音频/视频的带宽范围：</p>
<ol>
<li>setMin/MaxVideoRecvBandwidth() 设置接收视频带宽</li>
<li>setMin/MaxAudioRecvBandwidth() 设置接收音频带宽</li>
<li>setMin/MaxVideoSendBandwidth() 设置发送视频带宽</li>
</ol>
<p>带宽最大值在SDP中有声明。</p>
<p>WebRTC可以提供数据通道，并且支持可靠/不可靠、有序/无序的数据传输。要支持数据通道，必须在创建WebRtcEndpoint时显式说明，默认是不允许创建数据通道的</p>
<div class="blog_h3"><span class="graybg">PlayerEndpoint</span></div>
<p>此端点从可Seek/不可Seek的媒体源中获取媒体流，并将流注入到KMS中。支持的URL格式：</p>
<ol>
<li>挂载到本地文件系统的文件：file:///path/to/file</li>
<li>提供RTSP协议的摄像头：rtsp://、rtsp://username:password@ip:port...</li>
<li>Web服务器：http(s):///path/to/file、http(s)://username:password@/path/to/file</li>
</ol>
<p>此端点支持以下操作：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">操作</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>play</td>
<td>开始播放媒体流，可以在pause后调用，恢复播放</td>
</tr>
<tr>
<td>stop</td>
<td>停止播放媒体流</td>
</tr>
<tr>
<td>pause</td>
<td>暂停播放媒体流</td>
</tr>
<tr>
<td>setPosition/getPosition</td>
<td>如果媒体源支持，可以用来执行seek操作</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">过滤器</span></div>
<p>这类媒体元素负责媒体的处理、机器视觉、AR等功能。 这些媒体元素的功能简述如下表：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">过滤器</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>ZBarFilter</td>
<td>检测二维码（QR）、条形码，一旦检测成功，就发布一个CodeFoundEvent事件。客户端可以侦听此事件并执行相应的操作</td>
</tr>
<tr>
<td>FaceOverlayFilter</td>
<td>检测人脸，叠加一个可配置的图像。用法示例：<br />
<pre class="crayon-plain-tag">FaceOverlayFilter filter = new FaceOverlayFilter.Builder(pipeline).build();
filter.setOverlayedImage("图片URL", -0.35F, -1.2F, 1.6F, 1.6F);</pre>
</td>
</tr>
<tr>
<td>GStreamerFilter</td>
<td>允许你在Kurento中使用GStreamer过滤器</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">Hubs</span></div>
<p>这类媒体对象能够管理多个媒体流。这些媒体对象的功能简述如下表：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">Hub</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>Composite</td>
<td>
<p>能够混合多个输入音频流</p>
<p>能够合并多个输入视频流，构成多画面</p>
</td>
</tr>
<tr>
<td>DispatcherOneToMany</td>
<td>把一个输入HubPort分发给所有输出HubPort</td>
</tr>
<tr>
<td>Dispatcher</td>
<td>运行在任意输入-输出HubPort对值之间路由</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">Kurento Utils JS</span></div>
<p>Utils JS用于简化浏览器端WebRTC应用的开发。</p>
<div class="blog_h3"><span class="graybg">安装</span></div>
<p>执行下面的命令安装：</p>
<pre class="crayon-plain-tag"># 基于NPM
npm install kurento-utils
# 基于Bower
bower install kurento-utils</pre>
<p>或者到<a href="http://builds.kurento.org/release/6.6.2/js/kurento-utils.min.js">这里下载</a>压缩后的JS文件。</p>
<div class="blog_h3"><span class="graybg">创建连接</span></div>
<p>WebRtcPeer对RTCPeerConnection进行了包装。连接可以是单向的（进行发送或者接收），也可以是双向的（同时发送接收）。</p>
<p>下面的例子示意了如何基于Utils JS创建一个RTCPeerConnection，并与其它Peer进行会话协商：</p>
<pre class="crayon-plain-tag">// 信号处理通道，由你自己决定如何实现，它能够让客户端知道可以和谁通信、如何通信
// 典型的做法是，所有客户端公开一个自己的名字，同时以一条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 );
    };
}</pre>
<p>简述一下上例对应的业务流程：</p>
<ol>
<li>通信发起方A，根据接受方B的标识符，向服务器发送WS请求 —— 我要和B通信</li>
<li>服务器通过WS推送信息给B，A想和你通信，你愿意吗？</li>
<li>如果B愿意，服务器通过WS推送消息给A、B，你们可以通信了</li>
<li>A、B分别创建连接对象（WebRtcPeer）</li>
<li>WebRtcPeer会自动收集Candidate，你应该通过WS把Candidate发回服务器，服务器再中转给Peer</li>
<li>一单A、B都收集到Candidate，它们就有可能进行点对点通信了（如果是局域网内）</li>
<li>A发起（Offer）一个会话描述（SDP），B接收到后，给出Answer</li>
<li>根据双方的SDP，建立媒体流交换</li>
</ol>
<div class="blog_h3"><span class="graybg">使用数据通道</span></div>
<p>数据通道允许你通过活动WebRTC连接传递二进制、文本数据。WebRtcPeer对数据通道的使用也提供了封装，将dataChannels选项设置为true即可使用：</p>
<pre class="crayon-plain-tag">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 );</pre>
<p>一旦webRtcPeer对象被创建，你就可以调用下面的方法，通过数据通道发送信息：</p>
<pre class="crayon-plain-tag">// 发送的数据类型取决于应用
webRtcPeer.send('Hello there');</pre>
<p>数据通道的生命周期受限于其依赖的连接，<pre class="crayon-plain-tag">webRtcPeer.dispose()</pre>被调用后数据通道也被关闭和释放。 </p>
<div class="blog_h1"><span class="graybg"><span class="graybg">Kurento模块</span></span></div>
<p>Kurento是一个可拔插的框架，它的每个插件称为模块。模块分为三类。</p>
<div class="blog_h2"><span class="graybg">主模块</span></div>
<p>这类模块安装了KMS就可以使用，包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">模块</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>kms-core</td>
<td>KMS的核心功能，基于C编写</td>
</tr>
<tr>
<td>kms-elements</td>
<td>实现媒体元素，例如WebRtcEndpoint、WebRtcEndpoint</td>
</tr>
<tr>
<td>kms-filters</td>
<td>实现过滤器，例如FaceOverlayFilter, ZBarFilter, GStreamerFilter</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">内置模块</span></div>
<p>这些模块用于增强KMS的基本功能，没有随KMS安装，包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">模块</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>kms-pointerdetector</td>
<td>
<p>一个过滤器，基于颜色追踪在视频流中检测点（pointers），执行下面的命令安装：</p>
<pre class="crayon-plain-tag">sudo apt-get install kms-pointerdetector-6.0</pre>
</td>
</tr>
<tr>
<td>kms-chroma</td>
<td>
<p>一个过滤器，在一个层上让指定的色彩范围变得透明，这样下面层的图像就会显示出来。执行下面的命令安装：
<pre class="crayon-plain-tag">sudo apt-get install kms-chroma-6.0</pre>
</td>
</tr>
<tr>
<td>kms-crowddetector</td>
<td>
<p>过滤器，能够检测人群聚集。执行下面的命令安装：
<pre class="crayon-plain-tag">sudo apt-get install kms-crowddetector-6.0</pre>
</td>
</tr>
<tr>
<td>kms-platedetector</td>
<td>
<p>过滤器，能够实现车牌检测。执行下面的命令安装：
<pre class="crayon-plain-tag">sudo apt-get install kms-platedetector-6.0</pre>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">自定义模块</span></div>
<p>你可以根据需要<a href="http://doc-kurento.readthedocs.io/en/stable/mastering/develop_kurento_modules.html">自己扩展KMS模块</a>。
<div class="blog_h1"><span class="graybg"><span class="graybg">实例</span></span></div>
<div class="blog_h2"><span class="graybg"><a id="loopback"></a>HelloWorld</span></div>
<p>这是一个环回视频流的例子 —— 视频流发送给自己，需要一台客户端即可测试。通信流程如下：</p>
<ol>
<li>页面加载时，客户端自动创建一个到服务器的wss连接：<br />
<pre class="crayon-plain-tag">var ws = new WebSocket('wss://' + location.host + '/helloworld'); </pre></p>
<p>信号处理依赖此wss连接进行，信号格式为JSON，其id字段表示消息的类型。</p>
</li>
<li>用户点击页面上的开始按钮，执行下面的逻辑：<br />
<pre class="crayon-plain-tag">var 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 ); </pre></p>
<p>也就是说，作为通信发起方：</p>
<ol>
<li>创建一个连接对象WebRtcPeerSendrecv，此对象创建后，本地流立即就显示在localVideo这个video标签中</li>
<li>创建完毕后，即生成SDP，其内容如下（主要是发起方允许的连接方式、支持的媒体特性）：<br />
<pre class="crayon-plain-tag">v=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 0
a=candidate:364622241 1 udp 2122194687 10.255.0.1 49487 typ host generation 0
a=candidate:1051995033 1 udp 2122129151 172.18.0.1 52714 typ host generation 0
a=candidate:410389623 1 udp 2122063615 172.21.0.1 54819 typ host generation 0
a=candidate:2199032595 1 udp 2121998079 192.168.1.89 47718 typ host generation 0
a=candidate:627415207 1 udp 2121932543 192.168.0.89 52455 typ host generation 0
a=candidate:2999745851 2 udp 2122260222 192.168.56.1 51004 typ host generation 0
a=candidate:364622241 2 udp 2122194686 10.255.0.1 59954 typ host generation 0
a=candidate:1051995033 2 udp 2122129150 172.18.0.1 41985 typ host generation 0
a=candidate:410389623 2 udp 2122063614 172.21.0.1 59234 typ host generation 0
a=candidate:2199032595 2 udp 2121998078 192.168.1.89 58222 typ host generation 0
a=candidate:627415207 2 udp 2121932542 192.168.0.89 36590 typ host generation 0
# 下面两行是ICE参数
a=ice-ufrag:Oyu3vwR19M1nxsx4
a=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:6C
a=setup:actpass
# 用在BUNDLE中的标识符
a=mid:audio
# 定义RTP扩展头
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
# 同时支持接收、发送
a=sendrecv
# 支持RTCP多路复用
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: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:126 telephone-event/8000
a=maxptime:60
# SSRC参数
a=ssrc:2978616353 cname:GrA29DQMxaUfd99u
a=ssrc:2978616353 msid:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t 97776675-4490-4b74-a849-bbd46a722c89
a=ssrc:2978616353 mslabel:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t
a=ssrc:2978616353 label:97776675-4490-4b74-a849-bbd46a722c89
m=video 46497 UDP/TLS/RTP/SAVPF 100 101 116 117 96 97 98
c=IN IP4 192.168.56.1
a=rtcp:9 IN IP4 0.0.0.0
a=candidate:2999745851 1 udp 2122260223 192.168.56.1 46497 typ host generation 0
a=candidate:364622241 1 udp 2122194687 10.255.0.1 34284 typ host generation 0
a=ice-ufrag:Oyu3vwR19M1nxsx4
a=ice-pwd:8RbNWdv799Hz7aXWj2DMIPGH
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:6C
a=setup:actpass

### 视频行 ### 
a=mid:video
a=extmap:2 urn:ietf:params:rtp-hdrext:toffset
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:4 urn:3gpp:video-orientation
a=sendrecv
a=rtcp-mux
# 支持的视频编码
a=rtpmap:100 VP8/90000
# 如果客户端是Firefox、Chrome 61 —— 支持H264
a=rtpmap:120 VP8/90000
a=rtpmap:126 H264/90000
a=rtpmap:97 H264/90000
# 则出现以上三行
a=rtcp-fb:100 ccm fir
a=rtcp-fb:100 nack
a=rtcp-fb:100 nack pli
a=rtcp-fb:100 goog-remb
a=rtcp-fb:100 transport-cc
a=rtpmap:101 VP9/90000
a=rtcp-fb:101 ccm fir
a=rtcp-fb:101 nack
a=rtcp-fb:101 nack pli
a=rtcp-fb:101 goog-remb
a=rtcp-fb:101 transport-cc
a=rtpmap:116 red/90000
a=rtpmap:117 ulpfec/90000
a=rtpmap:96 rtx/90000
a=fmtp:96 apt=100
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=101
a=rtpmap:98 rtx/90000
a=fmtp:98 apt=116
a=ssrc-group:FID 3977515695 1979665708
a=ssrc:3977515695 cname:GrA29DQMxaUfd99u
a=ssrc:3977515695 msid:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t 153f4d5f-ba5b-4772-8700-aff4474d8652
a=ssrc:3977515695 mslabel:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t
a=ssrc:3977515695 label:153f4d5f-ba5b-4772-8700-aff4474d8652
a=ssrc:1979665708 cname:GrA29DQMxaUfd99u
a=ssrc:1979665708 msid:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t 153f4d5f-ba5b-4772-8700-aff4474d8652
a=ssrc:1979665708 mslabel:g8OrPwtoxMptMUo5k12OkvC8opycXELqVG9t
a=ssrc:1979665708 label:153f4d5f-ba5b-4772-8700-aff4474d8652</pre>
</li>
<li>上述SDP以消息类型start发送给服务器</li>
</ol>
</li>
<li>服务器接收到start消息后，执行以下逻辑：<br />
<pre class="crayon-plain-tag">// 创建媒体管线
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);

// 处理SDP
String 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&lt;IceCandidateFoundEvent&gt;() {
  @Override
  public 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();</pre></p>
<p>服务器生成的SDP应答内容如下：</p>
<pre class="crayon-plain-tag">v=0
o=- 3713658153 3713658153 IN IP4 0.0.0.0
s=Kurento Media Server
c=IN IP4 0.0.0.0
t=0 0
a=msid-semantic: WMS kGkOSxP0iFTu9aRzm53BNz0fROtBq1HxLFje
a=group:BUNDLE audio video
m=audio 1 UDP/TLS/RTP/SAVPF 111 0
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=mid:audio
a=rtcp:9 IN IP4 0.0.0.0
a=rtpmap:111 opus/48000/2
a=rtpmap:0 PCMU/8000
a=setup:active
a=sendrecv
a=rtcp-mux
a=fmtp:111 minptime=10; useinbandfec=1
a=maxptime:60
a=ssrc:1475810019 cname:user35735626@host-c1cf1e49
a=ice-ufrag:/Jml
a=ice-pwd:RCpQ+o7Ybof5B5mxYDGM17
a=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
m=video 1 UDP/TLS/RTP/SAVPF 100
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=mid:video
a=rtcp:9 IN IP4 0.0.0.0
# 使用VP8作为视频编码格式
a=rtpmap:100 VP8/90000
a=rtcp-fb:100 ccm fir
a=rtcp-fb:100 nack
a=rtcp-fb:100 nack pli
a=rtcp-fb:100 goog-remb
a=setup:active
a=sendrecv
a=rtcp-mux
a=ssrc:101029323 cname:user35735626@host-c1cf1e49
a=ice-ufrag:/Jml
a=ice-pwd:RCpQ+o7Ybof5B5mxYDGM17
a=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</pre>
</li>
<li>客户端接收到startResponse消息后，调用下面的方法处理SDP应答：<br />
<pre class="crayon-plain-tag">webRtcPeer.processAnswer(message.sdpAnswer, function(error) {
    if (error) console.error(error);
});</pre>
</li>
<li>关于Ice Candidate的处理上面没有提及，这会异步的进行：
<ol>
<li>客户端连接创建后，就会自动收集ICE候选，一旦收集到，就调用如下回调：<br />
<pre class="crayon-plain-tag">function onIceCandidate(candidate) {
    ws.send(JSON.stringify({
        id : 'onIceCandidate',
        candidate : candidate
    }));
}</pre>
<p>候选的内容如下：</p>
<pre class="crayon-plain-tag">{
    // 此候选的通信地址
    "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
}</pre>
<p>也就是说，以消息类型onIceCandidate发送给服务器</p>
</li>
<li>服务器接收到onIceCandidate消息后，将其保存到用户对象中：<br />
<pre class="crayon-plain-tag">UserSession user = users.get(session.getId());
IceCandidate candidate = new IceCandidate(
    jsonCandidate.get("candidate").getAsString(),
    jsonCandidate.get("sdpMid").getAsString(),
    jsonCandidate.get("sdpMLineIndex").getAsInt()
);
user.addCandidate(candidate);</pre>
</li>
<li>
<p>随着客户端候选的收集，onIceCandidate消息会被发送很多次，后续的sdpMid可能是video，sdpMLineIndex可能是1</p>
</li>
<li>服务器端在创建端点后，也同样会自动收集ICE候选信息，并以iceCandidate消息发送给客户端。候选的内容如下：<br />
<pre class="crayon-plain-tag">{
    "candidate": "candidate:5 1 TCP 1019216383 172.21.0.6 9 typ host tcptype active",
    "sdpMid":"video",
    "sdpMLineIndex":1
}</pre>
</li>
<li>客户端做如下处理：<br />
<pre class="crayon-plain-tag">webRtcPeer.addIceCandidate(parsedMessage.candidate, function(error) {
    if (error)  console.error(error);
});</pre>
</li>
<li>
<p>随着服务器端候选的收集， iceCandidate消息也会被发送多次</p>
</li>
</ol>
</li>
<li>随着候选信息的收集，webRtcPeer有了足够的信息，它会在remoteView元素中渲染远程媒体流</li>
<li>当用户点击停止按钮后，调用<pre class="crayon-plain-tag">webRtcPeer.dispose()</pre>并发送一个stop类型的消息</li>
<li>服务器收到stop消息后，清理用户数据：<br />
<pre class="crayon-plain-tag">UserSession user = users.remove(session.getId());
user.release();</pre></p>
<ol>
<li>释放用户数据的时候，会调用<pre class="crayon-plain-tag">mediaPipeline.release()</pre>释放媒体管线</li>
</ol>
</li>
<li>页面卸载时，客户端自动关闭wss连接：<br />
<pre class="crayon-plain-tag">ws.close();</pre>
</li>
</ol>
<p>在这个HelloWorld例子中，媒体流不是简单的由客户端发给自己，而是由服务器中转。也就是说，通信的Peer是服务器、客户端。</p>
<div class="blog_h2"><span class="graybg">FaceOverlay</span></div>
<p>可以在上例的Loopback媒体管线上插入一个FaceOverlayFilter，在检测到人脸时，附加一个帽子图片到人脸上方：</p>
<pre class="crayon-plain-tag">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);</pre>
<div class="blog_h2"><span class="graybg">一对多广播</span></div>
<div class="blog_h3"><span class="graybg">发布者客户端</span></div>
<p>首先初始化连接：</p>
<pre class="crayon-plain-tag">// 仅仅需要发送数据，不需要接收
webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendonly(
    {
        localVideo : video,
        onicecandidate : function(){ /* 发送本地ICE候选信息给服务器 */ }
    },
    function( err ){
        webRtcPeer.generateOffer(function( err, offerSdp ){
            /* 发送SDP，消息类型presenter */
        });
    }
)</pre>
<p>服务器接收到presenter消息后，会发送一个presenterResponse消息过来。如果服务器同意当前客户端作为发布者，则发布者调用：</p>
<pre class="crayon-plain-tag">webRtcPeer.processAnswer(message.sdpAnswer);
// 否则关闭连接</pre>
<p>服务器发来的ICE候选消息的处理，和前面的例子一样。 </p>
<div class="blog_h3"><span class="graybg">查看者客户端</span></div>
<p>首先也是初始化连接：</p>
<pre class="crayon-plain-tag">webRtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly( options, function( err ){
    this.generateOffer(function(){
        /* 发送SDP，消息类型viewer */
    });
});</pre>
<p>viewerResponse、 服务器发来的ICE候选消息的处理，和发布者一样。</p>
<div class="blog_h3"><span class="graybg">服务器</span></div>
<p>当服务器接收到发布者发来的presenter消息时，执行：</p>
<ol>
<li>记录一个发布者的会话对象，本质上是基于WS客户端标识对发布者进行时别</li>
<li>创建媒体管线：<br />
<pre class="crayon-plain-tag">pipeline = kurento.createMediaPipeline();
// 设置发布者的端点对象
presenterUserSession.setWebRtcEndpoint(new WebRtcEndpoint.Builder(pipeline).build());
// 当服务器的ICE候选准备好之后，发送给发布者客户端：
presenterUserSession.getWebRtcEndpoint().addIceCandidateFoundListener( e-&gt; {
    // 作为iceCandidate事件发送
});

// 处理发布者的SDP
String sdpOffer = jsonMessage.getAsJsonPrimitive("sdpOffer").getAsString();
String sdpAnswer = presenterWebRtc.processOffer(sdpOffer);
// 然后以presenterResponse消息发送SDP应答给发布者

// 最后，为发布者收集ICE候选信息
presenterWebRtc.gatherCandidates();</pre>
</li>
<li>当接收到发布者的ICE候选后，把这些信息记录到代表发布者的会话对象中：<br />
<pre class="crayon-plain-tag">presenterUserSession.addCandidate(cand); // 处理方式和HelloWorld那个例子相同 </pre>
</li>
</ol>
<p>到目前为止，尚未发生任何媒体流的传输工作。因为没有人查看者。</p>
<p>当有查看者接入后，服务器首先收到一个viewer信息，并执行：</p>
<ol>
<li>如果当前没有发布者，返回viewerResponse消息，其response属性为rejected</li>
<li>如果当前有发布者，则为其创建UserSession对象、WebRtcEndpoint端点，并发此端点加入到之前创建的管线中：<br />
<pre class="crayon-plain-tag">UserSession viewer = new UserSession(webSocketSession);
WebRtcEndpoint nextWebRtc = new WebRtcEndpoint.Builder(pipeline).build();
viewer.setWebRtcEndpoint(nextWebRtc);
viewer.getWebRtcEndpoint().addIceCandidateFoundListener( e-&gt; {
    // 作为iceCandidate事件发送
});

// 重要：将发布者的SRC连接到查看者的SINK
presenterUserSession.getWebRtcEndpoint().connect(nextWebRtc);

// 处理查看者的SDP
String sdpOffer = jsonMessage.getAsJsonPrimitive("sdpOffer").getAsString();
// SDP应答总是调用请求者的端点对象获得
String sdpAnswer = nextWebRtc.processOffer(sdpOffer);
// 然后以viewerResponse消息发送SDP应答给发布者

// 最后，为观看者收集ICE候选信息
nextWebRtc.gatherCandidates();</pre>
</li>
</ol>
<p>当由更多的查看者连接进来后，发布者端点的SRC将连接到更多的SINK，呈现出星状结构。从ICE候选信息来看，貌似媒体流都是从服务器中转的。</p>
<div class="blog_h2"><span class="graybg">一对一视频电话</span></div>
<p>这个在实现上没有特别的地方，参与通话双方的WebRTC端点，需要配置为首尾相连。</p>
<p>此外，业务逻辑部分需要实现拒接之类的功能。</p>
<div class="blog_h2"><span class="graybg">多对多视频会议</span></div>
<p>相当于每个参与者都进行一对多广播。在实现时，往往会抽象出会议房间（Group）的概念，房间内的每个人都需要对其它人进行广播。</p>
<p>每个参与者都需要创建一个发送端点，N-1个接收端点，一共N个video元素。</p>
<p>此外，一旦有新人加入、旧人退出，就需要通知房间的所有参与者，进行客户端资源清理、UI更新。</p>
<div class="blog_h1"><span class="graybg"><a id="vs"></a>视频监控</span></div>
<p>这类应用场景中，媒体流的来源主要有两类：</p>
<ol>
<li>基于ONVIF框架协议，视频流基于RTSP/RTP传输</li>
<li>由设备SDK提供，SDK可能提供标准格式的码流、视频帧，或者解码后的原始图像</li>
</ol>
<p>视频监控的主要需求包括：</p>
<ol>
<li>实时监控，特别是多画面实时监控</li>
<li>录像回放</li>
<li>视频分析，例如移动侦测、模式识别</li>
</ol>
<div class="blog_h2"><span class="graybg">封装</span></div>
<p>为了简化开发，我们对Kurento、信号处理进行了组件化封装。</p>
<div class="blog_h3"><span class="graybg">MediaSession</span></div>
<p>代表一个WebRTC客户端与Kurento的媒体会话：</p>
<pre class="crayon-plain-tag">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&lt;IceCandidate&gt; candidatesPending;

    public MediaSession( String id ) {
        this.id = id;
        candidatesPending = new ArrayList&lt;&gt;();
    }

    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 -&gt; {
                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 );
        }
    }
}</pre>
<div class="blog_h3"><span class="graybg">VideoSurveillanceApp</span></div>
<p>Spring Boot应用程序，信号处理以STOMP作为子协议：</p>
<pre class="crayon-plain-tag">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&lt;String, MediaSession&gt; sessions = new ConcurrentHashMap&lt;&gt;();

    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&lt;?&gt; preSend( Message&lt;?&gt; 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 );
    }
}</pre>
<div class="blog_h3"><span class="graybg">KurentoService</span></div>
<p>封装一些模板代码：</p>
<pre class="crayon-plain-tag">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 -&gt; {
            String dest = namespace + "/icecandidate";
            template.convertAndSendToUser( user, dest, event.getCandidate() );
        } );
        webRtcEndpoint.gatherCandidates();
        return webRtcEndpoint;
    }
} </pre>
<div class="blog_h3"><span class="graybg">StompClient </span></div>
<p>对stomp.js进行简单的封装：</p>
<ol>
<li>每个客户端在一个名字空间内操作</li>
<li>订阅总是针对/user前缀进行</li>
<li>发送总是针对/app前缀进行 </li>
</ol>
<p>代码如下：</p>
<pre class="crayon-plain-tag">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 ) =&gt; {
            this.connected = true;
            this.processPending();
        } );
    }

    processPending() {
        if ( this.connected ) {
            let pending = this.pending;
            this.pending = [];
            pending.forEach( callback =&gt; callback() );
        }
    }

    recv( destination, callback ) {
        this.pending.push( () =&gt; {
            this.stomp.subscribe( '/user' + this.namespace + destination, ( frame ) =&gt; {
                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( () =&gt; {
            this.stomp.send( '/app' + this.namespace + destination, {
                "content-type": "application/json;charset=UTF-8"
            }, this.encode( object ) );
        } );
        this.processPending();
    }

    disconnect() {
        this.stomp.disconnect();
    }
}</pre>
<div class="blog_h3"><span class="graybg">WebRTCEndpoint</span></div>
<p>对Kurento Utils的WebRtcPeer进行封装。 </p>
<p>WebRTCEndpoint的STOMP消息目的地格式：  前缀 + 名字空间 + 消息类型。消息类型包括：</p>
<ol>
<li>sdpoffer，表示浏览器客户端发起SDP邀请</li>
<li>sdpanswer，表示KMS客户端发给浏览器的SDP应答</li>
<li>icecandidate，双方交换ICE候选</li>
<li>stop，客户端请求停止会话</li>
<li>其它消息类型</li>
</ol>
<p>代码如下：</p>
<pre class="crayon-plain-tag">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 =&gt; {
            this.peer.addIceCandidate( candidate );
        } );
        stomp.recv( '/sdpanswer', answer =&gt; {
            this.peer.processAnswer( answer );
        } );
        options.onicecandidate = candidate =&gt; {
            stomp.send( '/icecandidate', candidate );
        }
        this.peer = webRtcPeerType( options, err =&gt; {
            this.peer.generateOffer( ( error, sdpOffer ) =&gt; {
                stomp.send( '/sdpoffer', sdpOffer );
            } );
        } );
        this.stomp = stomp;
    }

    dispose() {
        this.stomp.send( '/stop', "bye" );
        this.stomp.disconnect();
        this.peer &amp;&amp; this.peer.dispose();
    }
}
WebRTCEndpoint.MODE_SEND = 0;
WebRTCEndpoint.MODE_RECV = 1;
WebRTCEndpoint.MODE_SEND_RECV = 2; </pre>
<div class="blog_h2"><span class="graybg">RTSP接入</span></div>
<p>以下情况下可以考虑RTSP接入：</p>
<ol>
<li>IP摄像头或者NVR直接提供流RTSP协议服务器</li>
<li>通过SDK获取码流，手工创建RTSP协议服务器</li>
</ol>
<p>对于第二种方式，还可以考虑利用Kurento的RTPEndpint，直接通过RTP协议发送媒体流到KMS。</p>
<div class="blog_h3"><span class="graybg">媒体互操作性</span></div>
<p>IP摄像头常常会提供某种基于流的接入方式：</p>
<ol>
<li>RTSP/H.264：这类摄像头通常用在视频监控领域。它们通过RTSP协议来建立RTP媒体会话 —— 信号处理基于RTSP进行而媒体流直接通过RTP传输。不同的摄像头厂商支持的RTP profile可能不同，<a href="https://www.ietf.org/rfc/rfc3551.txt">AVP</a>（用于音视频会议的RTP profile，最小化控制。RTP Profile for Audio and Video Conferences<br /> with Minimal Control）是一种常用的profile。视频编码方式也有不同的选择，典型的是 H.264</li>
<li>HTTP/MJPEG：这类摄像头基于HTTP协议进行信号处理和媒体传输，视频流被编码为JPEG的序列。这类摄像头的硬件比较简单，资源（包括电量）消耗少但是视频质量差</li>
</ol>
<p>要实现WebRTC到IP摄像头的媒体互操作性，两者的码流格式必须兼容，这种码流转换的工作是由某种WebRTC网关负责的（例如Kurento）。此网关需要完成：</p>
<ol>
<li>和摄像头交互，也就是网关需要理解RTSP/RTP或者HTTP</li>
<li>解码从摄像头取得的码流，例如H264或者MJPEG</li>
<li>将码流重新编为浏览器支持的格式，例如VP8是WebRTC最广泛支持的编码</li>
<li>通过WebRTC协议把码流发送给客户端</li>
</ol>
<p>此工作流示意如下图：</p>
<p><img class="aligncenter size-large wp-image-15974" src="https://blog.gmem.cc/wp-content/uploads/2017/08/rtsp-gw-1024x366.png" alt="rtsp-gw" width="710" height="253" /></p>
<div class="blog_h3"><span class="graybg">关于H.264</span></div>
<p>在Chrome中WebRTC使用的视频编码格式一直是VP8/VP9，直到Chrome 50才支持H264。你可以使用标记enable-webrtc-h264-with-openh264-ffmpeg打开H264支持（最新的Chrome 61此标记默认是打开的）。</p>
<p>H264被微软Edge的ORTC、Firefox、移动设备、遗留视频系统支持。移动设备大部分支持H264硬件解码，这意味着播放视频不会过于消耗电池，这一点很关键。</p>
<p>目前的情况并不乐观，主要是不同系统对于H.264的支持程度不同，它们可能支持不兼容的Profile，因而存在互操作性问题。</p>
<div class="blog_h3"><span class="graybg"><a id="interoperability"></a>通信互操作性</span></div>
<p>WebRTC协议栈使用SAVPF这一RTP profile， 其含义是针对基于RTCP的反馈的扩展安全RTP profile（Extended Secure RTP Profile for Real-time Transport Control Protocol Based Feedback），SAVPF主要包括两个RTP profile：</p>
<ol>
<li>SAVP：AVP的基础上包含安全特性</li>
<li>AVPF：用于及时的向媒体流的发送者反馈信息</li>
</ol>
<p>SAVPF的意义在于，提供安全RTP通信的基础上支持反馈。WebRTC客户端会向WebRTC网关发送反馈信息（在RTCP包中），通知网关可能影响到媒体质量的网络状况。</p>
<p>大多数IP摄像头仅仅支持AVP，这意味着，网关无法把WebRTC的反馈传递给IP摄像头。网关必须自己管理好反馈信息，或者用行话说，网关必须终结（terminate）RTCP反馈。</p>
<p>这一点很重要，如果网关没有正确处理反馈，WebRTC客户端可能出现严重的QoS问题，通常是视频画面卡死。卡死的具体原因是：</p>
<ol>
<li>PLI（画面丢失提示，Picture Loss Indication）反馈：如果此反馈没有被网关正确处理，只要出现丢包，画面可能随机的卡死。这和VP8编码器的工作机制有关。VP8允许长时间没有关键帧生成（以分钟计），当PLI出现后网关应该立即生成新的关键帧，否则直到下一次关键帧（周期性的）到达，客户端都无法解码。某些网关的解决方式是，频繁的生成关键帧，这种做法的劣势是大量消耗带宽，导致视频质量差</li>
<li>REMB（接收者估算的最大比特率， Receiver Estimated Maximum Bitrate）反馈：如果网关没有处理此反馈，且没有任何拥塞控制机制，则网关就不可能指示VP8编码器降低比特率。这样随着接入的客户端便多，网络带宽不够用后，视频质量变差</li>
</ol>
<div class="blog_h3"><span class="graybg">Kurento中接入RTSP</span></div>
<p>将Kurento作为WebRTC网关时，上述互操作性问题已经被解决，你需要了解以下三点：</p>
<ol>
<li>PlayerEndpoint这个端点支持从各种各样的源读取视频流，这些源可以是RTSP/RTP、HTTP/MJPEG。这意味着PlayerEndpoint有能力从IP摄像头读取码流</li>
<li>WebRtcEndpoint这个端点支持完整的WebRTC协议栈，能够正确处理RTCP反馈：
<ol>
<li>每当PLI包被收到，WebRtcEndpoint会命令VP8编码器立即生成一个新的关键帧</li>
<li>内置了拥塞控制，且响应REMB包。必要时命令VP8编码器降低比特率</li>
</ol>
</li>
<li>不可知媒体特性：当两个不兼容的媒体元素连接在一起时，Kurento会自动进行编码格式转换。也就是说H.264/MJPEG到VP8的转码会自动发生，不需要开发人员干预</li>
</ol>
<p>RTSP到WebRTC的媒体管线示意如下：</p>
<p><img class="aligncenter size-large wp-image-15978" src="https://blog.gmem.cc/wp-content/uploads/2017/08/rtsp-kurento-1024x366.png" alt="rtsp-kurento" width="710" height="253" /></p>
<div class="blog_h3"><span class="graybg">单画面接入代码</span></div>
<p>客户端代码：</p>
<pre class="crayon-plain-tag">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;title&gt;WebRTC Video Surveillance - RTSP Preview&lt;/title&gt;
    &lt;script src="js/stomp.js"&gt;&lt;/script&gt;
    &lt;script src="js/stomp-wrapper.js"&gt;&lt;/script&gt;
    &lt;script src="js/adapter.js"&gt;&lt;/script&gt;
    &lt;script src="js/kurento-utils.js"&gt;&lt;/script&gt;
    &lt;script src="js/webrtc-endpoint.js"&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;body onunload="endpoint.dispose();"&gt;
&lt;div&gt;
    &lt;video id="remoteVideo" autoplay width="427px" height="240px"&gt;&lt;/video&gt;
&lt;/div&gt;
&lt;script&gt;
    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' )
    } );
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</pre>
<p>服务器代码：</p>
<pre class="crayon-plain-tag">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 -&gt; {
            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();
    }
}</pre>
<div class="blog_h3"><span class="graybg">多画面接入代码</span></div>
<p>没有什么本质区别，只有一些技术上的细节需要处理：</p>
<pre class="crayon-plain-tag">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;title&gt;WebRTC Video Surveillance - RTSP Preview&lt;/title&gt;
    &lt;script src="js/stomp.js"&gt;&lt;/script&gt;
    &lt;script src="js/stomp-wrapper.js"&gt;&lt;/script&gt;
    &lt;script src="js/adapter.js"&gt;&lt;/script&gt;
    &lt;script src="js/kurento-utils.js"&gt;&lt;/script&gt;
    &lt;script src="js/webrtc-endpoint.js"&gt;&lt;/script&gt;
    &lt;style&gt;
        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;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body onunload="dispose();"&gt;
&lt;div class="nvbar"&gt;
    &lt;div class="title"&gt;基于WebRTC+Kurento的视频监控示例&lt;/div&gt;
    &lt;div class="subtitle"&gt;http://172.21.0.1:9090/rtsp-preview.html&lt;/div&gt;
&lt;/div&gt;
&lt;div id="videos"&gt;&lt;/div&gt;

&lt;script&gt;
    let endpoints = [];
    let videos = document.getElementById( 'videos' );
    for ( let ch = 1; ch &lt;= 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 =&gt; e.dispose() );
    }
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</pre>
<p>服务器器端，每个视频通道使用一个名字空间（STOMP目的地中缀）： </p>
<pre class="crayon-plain-tag">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&lt;String, MediaPipeline&gt; mediaPipelines = new ConcurrentHashMap&lt;&gt;();

    @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 -&gt; {
            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 -&gt; {
                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地址";
    }
} </pre>
<p>即使开到21画面，客户端运行仍然非常流畅（ i7-4940MX ），完全可以满足视频监控领域的多画面需求：</p>
<p><img class="aligncenter size-large wp-image-15995" src="https://blog.gmem.cc/wp-content/uploads/2017/08/rtsp-21scr-1024x388.png" alt="rtsp-21scr" width="710" height="269" /></p>
<div class="blog_h2"><span class="graybg">强制H264</span></div>
<p>前面我们已经提到过，Chrome 61默认已经开启了H264支持，其它很多浏览器也支持H264。如果KMS不进行转码，则对服务器配置要求可以大大降低。</p>
<p>首先，为Kurento安装插件：</p>
<pre class="crayon-plain-tag">apt install openh264-gst-plugins-bad-1.5</pre>
<p>要强制KMS仅仅使用H264，可以修改KMS配置文件，注释掉VP8的支持：</p>
<pre class="crayon-plain-tag">"videoCodecs" : [
    {
      "name" : "H264/90000"
    }
]</pre>
<p>注意：</p>
<ol>
<li>一定要确保你的客户端都支持基于H264的WebRTC视频传输，才可以进行上述修改</li>
<li>进行上述修改后，如果客户端不支持H264，那么SDP应答将会不完整，缺少媒体格式说明：<br />
<pre class="crayon-plain-tag">m=video 0 UDP/TLS/RTP/SAVPF    # 后面缺少媒体格式代码</pre></p>
<p>这会导致客户端WebRTC报错：</p>
<pre class="crayon-plain-tag">Failed to parse SessionDescription. m=video 0 UDP/TLS/RTP/SAVPF Expects at least 4 fields.</pre>
</li>
</ol>
<div class="blog_h3"><span class="graybg">验证</span></div>
<p>通过SDP Offer/Answer可以查看浏览器和KMS协商使用H.264作为视频编码方式。
<p>在浏览器地址栏输入<pre class="crayon-plain-tag">chrome://webrtc-internals</pre>，搜索ssrc_，会发现两个匹配项，其中一个和视频相关。可以看到mediaType为video，codecImplementationName为FFmpeg， googCodecName为H264。</p>
<div class="blog_h3"><span class="graybg">问题</span></div>
<p>在公司的环境下测试，如果使用子码流的话，Chrome 61、Firefox都可以正常多画面播放。</p>
<p>但是采用主码流的情况下，运行效果实在太差：花屏、周期性卡死：</p>
<p><img class="aligncenter size-full wp-image-16100" src="https://blog.gmem.cc/wp-content/uploads/2017/08/webrtc-chrome61-h264.png" alt="webrtc-chrome61-h264" width="100%" /></p>
<p>打开chrome://webrtc-internals可以看到帧经常无法解析（framesDecoded计数不增加）。具体原因还需要深入研究，但我估计可能的相关因素有：</p>
<ol>
<li>根据SDP，摄像头的H.264 Profile是420029，即Baseline；而Chrome支持的H.264 Profile是42e01f，即Constrained Baseline。也就是两者的Profile不兼容。这导致Kurento需要进行转码</li>
<li>如果进行SDP伪造，让Kurento相信Chrome支持420029，则完全无法播放。这意味着Chrome可能的确无法解码420029</li>
<li>转码工作依赖GST插件openh264-gst-plugins-bad-1.5完成，此插件可能存在质量问题</li>
</ol>
<p><em>9月19日更新：</em></p>
<p>被摄像头给骗了……SDP声称的H.264 Profile和它实际使用的Profile并不一致。默认情况下这款摄像头使用的H.264 Profile为Main，<a href="/research-on-html5-video-surveillance#hk-av-config">手工配置</a>之后则可以使用Baseline。</p>
<p>不过，就算改成Baseline，和Chrome/Firefox支持的Constrained Baseline仍然不兼容（注意：实际上很多编码器不使用Baseline特性针对Constrained Baseline的差集，也就是说两个Profile的编码结果很可能是兼容的）。进行SDP伪造的话，播放花屏、很快卡死。 </p>
<div class="blog_h2"><span class="graybg">服务器端多画面合成</span></div>
<p>很多情况下，监控客户端都开启重要视频通道构成的固定多画面监控。这种情况下，可以考虑在流媒体服务器端把多画面合成GRID（例如四画面、九画面），好处是：</p>
<ol>
<li>降低客户端解码压力</li>
<li>降低通信复杂度，不需要开启多个媒体连接甚至信号连接了</li>
</ol>
<p>&nbsp;</p>
<div class="blog_h1"><span class="graybg">术语列表</span></div>
<div class="blog_h2"><span class="graybg">WebRTC术语</span></div>
<p>更多WebRTC术语参考<a href="https://webrtcglossary.com/">webrtcglossary</a>。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">术语</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>ICE</td>
<td>
<p>交互式连接建立（Interactive Connectivity Establishment）是WebRTC进行<a href="/network-faq#traversal">NAT穿透</a>的标准协议，由<a href="https://tools.ietf.org/html/rfc5245">IETF RFC 5245</a>定义。取决于candidate，ICE可能尝试直连、STUN、TURN —— ICE负责协调这三种底层连接机制</p>
<p>ICE通过指导连接性检测，来处理基于NATs的的媒体流连接。ICE收集所有可用的候选（candidate，可供Peer连接的地址信息）：</p>
<ol>
<li>对于STUN来说是本地IP地址、反射（reflexive）地址</li>
<li>对于TURN来说是中继地址</li>
</ol>
<p>所有收集到的candidate通过SDP发送给Peer</p>
<p>一旦WebRTC收集流自己的、Peer的所有ICE地址之后，它就开始初始化连接性测试，逐个通过candidate发送媒体流直到成功</p>
<p>使用ICE的缺点是，会引入延迟（可能高达10s），新协议Trickle ICE用于解决此问题</p>
</td>
</tr>
<tr>
<td>ICE-TCP</td>
<td>
<p>通过TCP而不是TURN来发送媒体流的机制，Chrome支持</p>
</td>
</tr>
<tr>
<td>MCU</td>
<td>
<p>多点会议单元（Multipoint Conferencing Unit）</p>
<p>jsonMessage.get("candidate")这种设备提供了在单个视频/音频会话中，连接很多参与者的能力。MCU通常都实现了Mixing架构，因而每个会话都需要消耗很多计算资源</p>
</td>
</tr>
<tr>
<td>Mixing</td>
<td>
<p>一种多点通信架构，每个参与者发送自己的媒体流到中心服务器，并从中心服务器接收混合后的单个媒体流。实现此架构的服务器称为MCU。此架构的：</p>
<ol>
<li>优势：对客户端要求低，客户端需要一个点对点连接</li>
<li>劣势：资源消耗高，因为服务器需要解码、布局、重新编码它接收到的媒体流</li>
</ol>
</td>
</tr>
<tr>
<td>SDP</td>
<td>
<p>会话描述协议（Session Description Protocol），WebRTC使用该协议来协商会话的参数，但是WebRTC不负责信号处理，因而SDP的创建和传输需要应用程序自己完成</p>
</td>
</tr>
<tr>
<td> SFU</td>
<td>
<p>选择性转发单元（Selective Forwarding Unit），有时用于描述一种视频路由设备，有时则用来描述一种路由特性</p>
<p>SFU能够接收多个媒体流，然后决定将其中的哪些流转发给哪些参与者</p>
</td>
</tr>
<tr>
<td>SIP</td>
<td>
<p>会话初始化协议（Session Initiation Protocol），一个在VoIP领域（电信行业）广泛使用的信号处理协议</p>
</td>
</tr>
<tr>
<td><a id="glossary-stun"></a>STUN</td>
<td>
<p>NAT用会话穿透工具（Session Traversal Utilities for NAT） 是WebRTC进行NAT穿透的标准方法</p>
<p>STUN的核心目的是，探测客户端的公共地址/端口：</p>
<ol>
<li>客户端发送STUN请求到服务器</li>
<li>服务器返回请求来自的公共地址信息</li>
<li>客户端通过SDP与Peer分析自己的公共地址信息</li>
</ol>
</td>
</tr>
<tr>
<td>Trickle ICE</td>
<td>
<p>对 ICE的优化。ICE的主要瓶颈是初始化连接性检测比较耗时，Trickle ICE通过并行尝试多种底层机制，以加速candidate的获取。一旦某个candidate可用客户端就可以立即进行下一步，不需要等待所有candidate</p>
</td>
</tr>
<tr>
<td>TURN</td>
<td>
<p>基于中继的NAT穿透（Traversal Using Relays around NAT）是WebRTC进行NAT穿透的标准方法</p>
<p>当STUN不可用的情况下，TURN基于TURN服务器中继所有媒体流，这可能导致昂贵的流量和CPU开销</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">常见问题</span></div>
<div class="blog_h2"><span class="graybg">如何循环播放</span></div>
<p>注册监听器，当流结束后，重新调用play()：</p>
<pre class="crayon-plain-tag">playerEndpoint.addEndOfStreamListener( e -&gt; {
    playerEndpoint.play();
} );</pre>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/webrtc-server-basedon-kurento">基于Kurento搭建WebRTC服务器</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/webrtc-server-basedon-kurento/feed</wfw:commentRss>
		<slash:comments>31</slash:comments>
		</item>
		<item>
		<title>HTML5视频监控技术预研</title>
		<link>https://blog.gmem.cc/research-on-html5-video-surveillance</link>
		<comments>https://blog.gmem.cc/research-on-html5-video-surveillance#comments</comments>
		<pubDate>Mon, 28 Aug 2017 05:49:57 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[C++]]></category>
		<category><![CDATA[Graphic]]></category>
		<category><![CDATA[HTML]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[JSMpeg]]></category>
		<category><![CDATA[MSE]]></category>
		<category><![CDATA[Multimedia]]></category>
		<category><![CDATA[WebRTC]]></category>
		<category><![CDATA[视频监控]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=15526</guid>
		<description><![CDATA[<p>引言 安防类项目中通常都有视频监控方面的需求。视频监控客户端主要是Native应用的形式，在Web端需要利用NPAPI、ActiveX之类的插件技术实现。 但是，IE式微，Chrome也放弃了NPAPI，另一方面，监控设备硬件厂商的视频输出格式则逐渐标准化。这让基于开放、标准化接口的Web视频监控成为可能。 本文讨论以HTML5及其衍生技术为基础的B/S架构实时视频监控解决方案。主要包括两方面的内容： 视频编码、流媒体基础知识，以及相关的库、框架的介绍 介绍可以用于视频监控的HTML5特性，例如媒体标签、MSE、WebRTC，以及相关的库、框架 本文仅仅简介若干种备选的解决方案，本站其它文章进行了更加深入的探讨： H.264学习笔记 实时通信协议族 基于Kurento搭建WebRTC服务器 基于Broadway的HTML5视频监控 音视频编码 音频、视频的编码（Codec，压缩）算法有很多，不同浏览器对音视频的编码算法的支持有差异。H264这样的监控设备常用的视频编码格式，主流浏览器都有某种程度的支持。 常见的音频编码算法包括： MP3, Vorbis, AAC；常见的视频编码算法包括： H.264, HEVC, VP8, VP9。 编码后的音频、视频通常被封装在一个比特流容器格式（container）中，这些格式中常见的有： <a class="read-more" href="https://blog.gmem.cc/research-on-html5-video-surveillance">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/research-on-html5-video-surveillance">HTML5视频监控技术预研</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">引言</span></div>
<p>安防类项目中通常都有视频监控方面的需求。视频监控客户端主要是Native应用的形式，在Web端需要利用NPAPI、ActiveX之类的插件技术实现。</p>
<p>但是，IE式微，Chrome也放弃了NPAPI，另一方面，监控设备硬件厂商的视频输出格式则逐渐标准化。这让基于开放、标准化接口的Web视频监控成为可能。</p>
<p>本文讨论以HTML5及其衍生技术为基础的B/S架构实时视频监控解决方案。主要包括两方面的内容：</p>
<ol>
<li>视频编码、流媒体基础知识，以及相关的库、框架的介绍</li>
<li>介绍可以用于视频监控的HTML5特性，例如媒体标签、MSE、WebRTC，以及相关的库、框架</li>
</ol>
<p>本文仅仅简介若干种备选的解决方案，本站其它文章进行了更加深入的探讨：</p>
<ol>
<li><a href="/h264-study-note">H.264学习笔记</a></li>
<li><a href="/realtime-communication-protocols">实时通信协议族</a></li>
<li><a href="/webrtc-server-basedon-kurento">基于Kurento搭建WebRTC服务器</a></li>
<li><a href="/html5-vs-with-broadway">基于Broadway的HTML5视频监控</a></li>
</ol>
<div class="blog_h1"><span class="graybg">音视频编码</span></div>
<p>音频、视频的编码（Codec，压缩）算法有很多，不同浏览器对音视频的编码算法的支持有<a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats#Browser_compatibility">差异</a>。H264这样的监控设备常用的视频编码格式，主流浏览器都有某种程度的支持。</p>
<p>常见的音频编码算法包括： MP3, Vorbis, AAC；常见的视频编码算法包括： H.264, HEVC, VP8, VP9。</p>
<p>编码后的音频、视频通常被封装在一个比特流容器格式（container）中，这些格式中常见的有： MP4, FLV, WebM,  ASF, ISMA等。</p>
<div class="blog_h2"><span class="graybg">JSMpeg</span></div>
<p>视频解码工作通常由浏览器本身负责，配合video实现视频播放。</p>
<p>现代浏览器的JS引擎性能较好，因此出现了纯粹由JS实现的解码器<a href="https://github.com/phoboslab/jsmpeg">JSMpeg</a>，它能够解码视频格式MPEG1、音频格式MP2。支持通过Ajax加载静态视频文件，支持低延迟（小于50ms）的流式播放（通过WebSocket）。JSMpeg包括以下组件：</p>
<ol>
<li>MPEG-TS分流器（demuxer）。muxer负责把视频、音频、字幕打包成一种容器格式，demuxer则作相反的工作</li>
<li>MPEG1视频解码器</li>
<li>MP2音频解码器</li>
<li>WebGL渲染器、Canvas2D渲染器</li>
<li>WebAudio音频输出组件</li>
</ol>
<p>JSMpeg的优势在于兼容性好，几乎所有现代浏览器都能运行JSMpeg。</p>
<div class="blog_h3"><span class="graybg">性能</span></div>
<p>JSMpeg不能使用硬件加速。在iPhone 5S这样的设备上，JSMpeg能够处理720p@30fps视频。</p>
<p>比起现代解码器，MPEG1压缩率较低，因而需要更大的带宽。720p的视频大概占用250KB/s的带宽。</p>
<div class="blog_h3"><span class="graybg">示例</span></div>
<p>下面我们尝试利用ffmpeg编码本地摄像头视频，并通过JSMpeg播放。</p>
<p>创建一个NPM项目，安装依赖：</p>
<pre class="crayon-plain-tag">npm install jsmpeg --save
npm install ws --save</pre>
<p>JSMpeg提供了一个中继器，能够把基于HTTP的MPEG-TS流转换后通过WebSocket发送给客户端。此脚本需要<a href="https://github.com/phoboslab/jsmpeg/blob/master/websocket-relay.js">到Github下载</a>。 下面的命令启动一个中继器：</p>
<pre class="crayon-plain-tag">node ./app/websocket-relay.js 12345 8800 8801
# Listening for incomming MPEG-TS Stream on http://127.0.0.1:8800/&lt;secret&gt;
# Awaiting WebSocket connections on ws://127.0.0.1:8801/
# 实际上在所有网络接口上监听，并非仅仅loopback</pre>
<p>下面的命令捕获本地摄像头（Linux），并编码为MPEG1格式，然后发送到中继器：</p>
<pre class="crayon-plain-tag"># 从摄像头/dev/video0以480的分辨率捕获原始视频流
ffmpeg -s 640x480 -f video4linux2 -i /dev/video0 \
       # 输出为原始MPEG-1视频（JSMpeg可用），帧率30fps，比特率800kbps
       -f mpegts -codec:v mpeg1video -b 800k -r 30 http://127.0.0.1:8800/12345
# 在我的机器上，上述ffmpeg私有内存占用18MB</pre>
<p>上述命令执行后，中继器控制台上打印：</p>
<pre class="crayon-plain-tag">Stream Connected: ::ffff:127.0.0.1:42399</pre>
<p>客户端代码：</p>
<pre class="crayon-plain-tag">var player = new JSMpeg.Player( 'ws://127.0.0.1:8801/', {
    canvas: document.getElementById( 'canvas' ),
    autoplay: true
} ); </pre>
<div class="blog_h2"><span class="graybg">Broadway</span></div>
<p><a href="https://github.com/mbebenita/Broadway">Broadway</a>是一个基于JavaScript的H.264解码器，其源码来自于Android的H.264解码器，利用Emscripten转译成了JavaScript，之后利用Google的Closure编译器优化，并针对WebGL进一步优化。</p>
<p>注意：Broadway仅仅支持Baseline这个H.264 Profile。</p>
<p><a href="https://github.com/131/h264-live-player">h264-live-player</a>是基于Broadway实现的播放器，允许通过WebSocket来传输NAL单元（原始H.264帧），并在画布上渲染。我们运行一下它的示例应用：</p>
<pre class="crayon-plain-tag">git clone https://github.com/131/h264-live-player.git
cd h264-live-player
npm install</pre>
<p>因为我的机器是Linux，所以修改h264-live-player/lib/ffmpeg.js， 把ffpmeg的参数改为：</p>
<pre class="crayon-plain-tag">var args = [
    "-f", "video4linux2",
    "-i",  "/dev/video0" ,
    "-framerate", this.options.fps,
    "-video_size", this.options.width + 'x' + this.options.height,
    '-pix_fmt',  'yuv420p',
    '-c:v',  'libx264',
    '-b:v', '600k',
    '-bufsize', '600k',
    '-vprofile', 'baseline',
    '-tune', 'zerolatency',
    '-f' ,'rawvideo',
    '-'
];</pre>
<p>然后运行<pre class="crayon-plain-tag">node server-ffmpeg</pre>，打开http://127.0.0.1:8080/，可以看到自己摄像头传来的H.264码流，效果还不错。</p>
<div class="blog_h2"><span class="graybg">服务器端技术</span></div>
<div class="blog_h3"><span class="graybg">ffpmeg</span></div>
<p>老牌的编解码库，支持很多的音频、视频格式的编解码，支持多种容器格式，支持多种流协议。关于ffpmeg的详细介绍参见<a href="/linux-command-faq#ffmpeg">Linux命令知识集锦</a>。</p>
<p>ffpmeg除了提供开发套件之外，还有一个同名的命令行工具，直接使用它就可以完成很多编解码、流转换的工作。</p>
<p>类似的库是libav，ffpmeg和它的功能非常相似，特性更多一些。</p>
<div class="blog_h3"><span class="graybg">x264</span></div>
<p>官网自称是最好的H.264编码器。特性包括：</p>
<ol>
<li>提供一流的性能、压缩比。特别是性能方面，可以在普通PC上并行编码4路或者更多的1080P流</li>
<li>提供最好的视频质量，具有最高级的心理视觉优化</li>
<li>支持多种不同应用程序所需要的特性，例如电视广播、蓝光低延迟视频应用、Web视频</li>
</ol>
<div class="blog_h1"><span class="graybg">流媒体技术</span></div>
<p>有了上面介绍的HTML5标签、合理编码的视频格式，就可以实现简单的监控录像回放了。但是，要进行实时监控画面预览则没有这么简单，必须依赖流媒体技术实现。</p>
<div class="blog_h2"><span class="graybg">流媒体</span></div>
<p>所谓多媒体（Multimedia）是指多种内容形式 —— 文本、音频、视频、图片、动画等的组合。</p>
<p>所谓流媒体，就是指源源不断的由提供者产生，并持续的被终端用户接收、展示的多媒体，就像水流一样。现实世界中的媒体，有些天生就是流式的，例如电视、广播，另外一些则不是，例如书籍、CD。</p>
<p>流媒体技术（从传递媒体角度来看）可以作为文件下载的替代品。</p>
<p><span style="background-color: #c0c0c0;">流媒体技术关注的是如何传递媒体，而不是如何编码媒体</span>，具体的实现就是各种流媒体协议。封装后的媒体比特流（容器格式）由流媒体服务器递送到流媒体客户端。流媒体协议可能对底层容器格式、编码格式有要求，也可能没有任何要求。</p>
<div class="blog_h2"><span class="graybg">直播</span></div>
<p>直播流（Live streaming）和静态文件播放的关键差异：</p>
<ol>
<li>点播的目标文件通常位于服务器上，具有一定的播放时长、文件大小。浏览器可以使用渐进式下载，一边下载一边播放</li>
<li>直播不存在播放起点、终点。它表现为一种流的形式，源源不断的从视频采集源通过服务器，传递到客户端</li>
<li>直播流通常是自适应的（adaptive），其码率随着客户端可用带宽的变化，可能变大、变小，以尽可能消除延迟</li>
</ol>
<p>流媒体技术不但可以用于监控画面预览，也可以改善录像播放的用户体验，比起简单的静态文件回放，流式回放具有以下优势：</p>
<ol>
<li>延迟相对较低，播放能够尽快开始</li>
<li>自适应流可以避免卡顿</li>
</ol>
<div class="blog_h2"><span class="graybg">流协议</span></div>
<p>主流的用于承载视频流的流媒体协议包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 18%; text-align: center;">协议</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>HLS</td>
<td>
<p>HTTP实时流（HTTP Live Streaming），由苹果开发，基于HTTP协议</p>
<p>HLS的工作原理是，把整个流划分成一个个较小的文件，客户端在建立流媒体会话后，基于HTTP协议下载流片段并播放。客户端可以从多个服务器（源）下载流。</p>
<p>在建立会话时，客户端需要下载extended M3U (m3u8) 播放列表文件，其中包含了MPEG-2 TS（Transport Stream）容器格式的视频的列表。在播放完列表中的文件后，需要再次下载<span style="color: #444444;">m3u8，如此循环</span></p>
<p>此协议在移动平台上支持较好，目前的Android、iOS版本都支持</p>
<p>此协议的重要缺点是高延迟（5s以上通常），要做到低延迟会导致频繁的缓冲（下载新片段）并对服务器造成压力，不适合视频监控</p>
<p>播放HLS流的HTML代码片段：</p>
<pre class="crayon-plain-tag">&lt;video src="http://movie.m3u8" height="329" width="480"&gt;&lt;/video&gt;</pre>
</td>
</tr>
<tr>
<td>RTMP</td>
<td>
<p>实时消息协议（Real Time Messaging Protocol），由Macromedia（Adobe）开发。此协议实时性很好，需要Flash插件才能在客户端使用，但是Adobe已经打算在不久的将来放弃对Flash的支持了
<p>有一个开源项目<a href="https://github.com/Bilibili/flv.js">HTML5 FLV Player</a>，它支持在没有Flash插件的情况下，播放Flash的视频格式FLV。此项目依赖于<a href="https://w3c.github.io/media-source/">MSE</a>，支持以下特性：</p>
<ol>
<li>支持H.264 + AAC/MP3编码的FLV容器格式的播放</li>
<li>分段（segmented）视频播放</li>
<li>基于HTTP的FLV低延迟实时流播放</li>
<li>兼容主流浏览器</li>
<li>资源占用低，可以使用客户端的硬件加速</li>
</ol>
</td>
</tr>
<tr>
<td>RTSP</td>
<td>
<p>实时流协议（Real Time Streaming Protocol），由RealNetworks等公司开发。此协议负责控制通信端点（Endpoint）之间的媒体会话（media sessions） —— 例如播放、暂停、录制。通常需要结合：实时传输协议（Real-time Transport Protocol）、实时控制协议（Real-time Control Protocol）来实现视频流本身的传递</p>
<p>大部分浏览器没有对RTSP提供原生的支持</p>
<p>RTSP 2.0版本目前正在开发中，和旧版本不兼容</p>
</td>
</tr>
<tr>
<td>MPEG-DASH</td>
<td>
<p>基于HTTP的动态自适应流（Dynamic Adaptive Streaming over HTTP），它类似于HLS，也是把流切分为很小的片段。DASH为支持为每个片段提供多种码率的版本，以满足不同客户带宽</p>
<p>协议的客户端根据自己的可用带宽，选择尽可能高（避免卡顿、重新缓冲）的码率进行播放，并根据网络状况实时调整码率</p>
<p>DASH不限制编码方式，你可以使用H.265, H.264, VP9等视频编码算法</p>
<p>Chrome 24+、Firefox 32+、Chrome for Android、IE 10+支持此格式</p>
<p>类似于HLS的高延迟问题也存在</p>
</td>
</tr>
<tr>
<td>WebRTC</td>
<td>
<p>WebRTC是一整套API，为浏览器、移动应用提供实时通信（RealTime Communications）能力。它包含了流媒体协议的功能，但是不是以协议的方式暴露给开发者的</p>
<p>WebRTC支持Chrome 23+、Firefox 22+、Chrome for Android，提供Java / Objective-C绑定</p>
<p>WebRTC主要有三个职责：</p>
<ol>
<li>捕获客户端音视频，对应接口MediaStream（也就是getUserMedia）</li>
<li>音视频传输，对应接口RTCPeerConnection</li>
<li>任意数据传输，对应接口RTCDataChannel</li>
</ol>
<p>WebRTC内置了点对点的支持，也就是说流不一定需要经过服务器中转</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">服务器端技术</span></div>
<p>视频监控通常都是CS模式（而非P2P），在服务器端，你需要部署流媒体服务。</p>
<div class="blog_h3"><span class="graybg">GStreamer</span></div>
<p><a href="https://gstreamer.freedesktop.org/">这是</a>一个开源的跨平台多媒体框架。通过它你可以构建各种各样的媒体处理组件，包括流媒体组件。通过插件机制，GStreamer支持上百种编码格式，包括MPEG-1, MPEG-2, MPEG-4, H.261, H.263, H.264, RealVideo, MP3, WMV, FLV</p>
<p><a href="https://www.kurento.org/">Kurento</a>、<a href="http://www.flumotion.net/features/">Flumotion</a>是基于GStreamer构建的流媒体服务器软件。</p>
<div class="blog_h3"><span class="graybg">Live555</span></div>
<p><a href="http://www.live555.com/">Live555</a>是流媒体服务开发的基础库，支持 RTP/RTCP/RTSP/SIP等协议，适合在硬件资源受限的情况下使用（例如嵌入式设备）。</p>
<p>基于Live555的软件包括：</p>
<ol>
<li>Live555媒体服务器，完整的RTSP服务器</li>
<li>openRTSP，一个命令行程序，支持提供RTSP流、接收RTSP流、把RTSP流中的媒体录像到磁盘</li>
<li>playSIP，可以进行VoIP通话</li>
<li>liveCaster，支持组播的MP3流媒体服务</li>
</ol>
<div class="blog_h3"><span class="graybg">其它</span></div>
<p>流媒体服务实现有很多，它们中的一些在最初针对特定的流协议，大部分都走向多元化。例如，Red5是一个RTMP流媒体服务器，Wowza是一个综合的流媒体服务器，支持WebRTC的流媒体服务在后面的章节介绍。</p>
<div class="blog_h1"><span class="graybg">HTML5媒体标签</span></div>
<p>HTML5支持<pre class="crayon-plain-tag">&lt;audio&gt;</pre>和<pre class="crayon-plain-tag">&lt;video&gt;</pre>标签（两者都对应了HTMLMediaElement的子类型）以实现视频、音频的播放。</p>
<div class="blog_h2"><span class="graybg">&lt;audio&gt;</span></div>
<p>此标签用于在浏览器中创建一个纯音频播放器。播放静态文件的示例：</p>
<pre class="crayon-plain-tag">&lt;audio controls preload="auto"&gt;
    &lt;source src="song.mp3" type="audio/mpeg"&gt;
    &lt;!-- 备选格式，如果浏览器不支持mp3 --&gt;
    &lt;source src="song.ogg" type="audio/ogg"&gt;
    &lt;!-- 如果浏览器不支持audio标签，显示下面的连接 --&gt;
    &lt;a href="audiofile.mp3"&gt;download audio&lt;/a&gt;
&lt;/audio&gt;</pre>
<div class="blog_h2"><span class="graybg">&lt;video&gt;</span></div>
<p>此标签用于在浏览器中创建一个视频播放器。播放静态文件的示例：</p>
<pre class="crayon-plain-tag">&lt;!-- poster指定预览图，autoplay自动播放，muted静音 --&gt;
&lt;video controls width="640" height="480" poster="movie.png" autoplay muted&gt;
  &lt;source src="movie.mp4" type="video/mp4"&gt;
  &lt;!-- 备选格式，如果浏览器不支持mp4 --&gt;
  &lt;source src="movie.webm" type="video/webm"&gt;
  &lt;!-- 可以附带字幕 --&gt;
  &lt;track src="subtitles_en.vtt" kind="subtitles" srclang="en" label="English"&gt;
  &lt;!-- 如果浏览器不支持video标签，显示下面的连接 --&gt;
  &lt;a href="videofile.mp4"&gt;download video&lt;/a&gt;
&lt;/video&gt;</pre>
<div class="blog_h2"><span class="graybg">&lt;canvas&gt;</span></div>
<p>在画布中，你可以进行任意的图形绘制，当然可以去逐帧渲染视频内容。</p>
<div class="blog_h2"><span class="graybg">编程方式创建</span></div>
<p>音频、视频播放器标签也可以利用JavaScript编程式的创建，示例代码：</p>
<pre class="crayon-plain-tag">var video = document.createElement( 'video' );
if ( video.canPlayType( 'video/mp4' ) ) {
    video.setAttribute( 'src', 'movie.mp4' );
}
else if ( video.canPlayType( 'video/webm' ) ) {
    video.setAttribute( 'src', 'movie.webm' );
}
video.width = 640;
video.height = 480; </pre>
<div class="blog_h1"><span class="graybg">MSE</span></div>
<p>媒体源扩展（Media Source Extensions，MSE）是一个W3C草案，<a href="http://caniuse.com/#feat=mediasource">桌面浏览器对MSE的支持较好</a>。MSE扩展流video/audio元素的能力，允许你<span style="background-color: #c0c0c0;">通过JavaScript来生成（例如从服务器抓取）媒体流供video/audio元素播放</span>。使用MSE你可以：</p>
<ol>
<li>通过JavaScript来构建媒体流，不管媒体是如何捕获的</li>
<li>处理自适应码流、广告插入、时间平移（time-shifting，回看）、视频编辑等应用场景</li>
<li>最小化JavaScript中处理媒体解析的代码</li>
</ol>
<p>MSE定义支持的（你生成的）<a href="https://www.w3.org/TR/media-source/">媒体格式</a>，只有符合要求的容器格式、编码格式才能被MSE处理。通常容器格式是<span style="color: #24292e;">ISO BMFF（MP4），也就是说你需要生成MP4的片断，然后Feed给MSE进行播放。</span></p>
<p>MediaSource对象作为video/audio元素的媒体来源，它可以具有多个SourceBuffer对象。应用程序把数据片段（segment）附加到SourceBuffer中，并可以根据系统性能对数据片段的质量进行适配。SourceBuffer中包含多个track buffer —— 分别对应音频、视频、文本等可播放数据。这些数据被音频、视频解码器解码，然后在屏幕上显示、在扬声器中播放：</p>
<p> <img class="aligncenter size-large wp-image-15564" src="https://blog.gmem.cc/wp-content/uploads/2017/08/pipeline_model.png" alt="pipeline_model" width="710" height="516" /></p>
<p>要把MediaSource提供给video/audio播放，调用：</p>
<pre class="crayon-plain-tag">video.src = URL.createObjectURL(mediaSource);</pre>
<div class="blog_h2"><span class="graybg">基于MSE的框架</span></div>
<div class="blog_h3"><span class="graybg">wfs</span></div>
<p><a href="https://github.com/ChihChengYang/wfs.js">wfs</a>是一个播放原始H.264帧的HTML5播放器，它的工作方式是把H.264 NAL单元封装为 ISO BMFF（MP4）片，然后Feed给MSE处理。</p>
<div class="blog_h3"><span class="graybg">flv.js</span></div>
<p><a href="https://github.com/Bilibili/flv.js">flv.js</a>是一个HTML5 Flash视频播放器，基于纯JS，不需要Flash插件的支持。此播放器将FLV流转换为ISO BMFF（MP4）片断，然后把MP4片断提供给video元素使用。</p>
<p>flv.js支持Chrome 43+, FireFox 42+, Edge 15.15048+以上版本的直播流 。</p>
<div class="blog_h3"><span class="graybg">Streamedian</span></div>
<p><a href="https://github.com/Streamedian/html5_rtsp_player/wiki/HTML5-RTSP-Player">Streamedian</a>是一个HTML5的RTSP播放器。实现了RTSP客户端功能，你可以利用此框架直接播放RTSP直播流。此播放器把RTP协议下的H264/AAC在转换为ISO BMFF供video元素使用。Streamedian支持Chrome 23+, FireFox 42+, Edge 13+，以及Android 5.0+。不支持iOS和IE。</p>
<p>在服务器端，你需要安装Streamedian提供的代理（此代理收费），此代理将RTSP转换为WebSocket。Streamedian处理视频流的流程如下：<img class="aligncenter size-large wp-image-15609" src="https://blog.gmem.cc/wp-content/uploads/2017/08/streamedian-1024x391.png" alt="streamedian" width="710" height="271" /></p>
<div class="blog_h1"><span class="graybg">WebRTC</span></div>
<p>WebRTC是一整套API，其中一部分供Web开发者使用，另外一部分属于要求浏览器厂商实现的接口规范。WebRTC解决诸如客户端流媒体发送、点对点通信、视频编码等问题。<a href="http://iswebrtcreadyyet.com/legacy.html">桌面浏览器对WebRTC的支持较好</a>，WebRTC也很容易和Native应用集成。</p>
<p>使用MSE时，你需要自己构建视频流。使用WebRTC时则可以直接捕获客户端视频流。</p>
<p>使用WebRTC时，大部分情况下流量不需要依赖于服务器中转，服务器的作用主要是：</p>
<ol>
<li>在信号处理时，转发客户端的数据</li>
<li>配合实现NAT/防火墙穿透</li>
<li>在点对点通信失败时，作为中继器使用</li>
</ol>
<div class="blog_h2"><span class="graybg">架构</span></div>
<p><img class="aligncenter size-full wp-image-15576" src="https://blog.gmem.cc/wp-content/uploads/2017/08/webrtcArchitecture.png" alt="webrtcarchitecture" width="100%" /></p>
<div class="blog_h2"><span class="graybg">流捕获</span></div>
<div class="blog_h3"><span class="graybg">捕获视频</span></div>
<p>主要是捕获客户端摄像头、麦克风。在视频监控领域用处不大，这里大概了解一下。流捕获通过navigator.getUserMedia调用实现： </p>
<pre class="crayon-plain-tag">&lt;script type="text/javascript"&gt;
    navigator.getUserMedia = navigator.webkitGetUserMedia || navigator.getUserMedia;
    var success = function ( stream ) {
        var video = document.getElementById( 'camrea' );
        // 把MediaStream对象转换为Blob URL，提供给video播放
        video.src = URL.createObjectURL( stream );
        video.play();
    }
    var error = function ( err ) {
        console.log( err )
    }
    // 调用成功后，得到MediaStream对象
    navigator.getUserMedia( { video: true, audio: true }, success, error );
&lt;/script&gt;
&lt;video id="camrea" width="640" height="480"/&gt;</pre>
<p>三个调用参数分别是：</p>
<ol>
<li><a href="http://io13webrtc.appspot.com/#22">约束条件</a>，你可以指定媒体类型、分辨率、帧率 </li>
<li>成功后的回调，你可以在回调中解析出URL提供给video元素播放</li>
<li>失败后的回调</li>
</ol>
<div class="blog_h3"><span class="graybg">捕获音频</span></div>
<p>捕获音频类似：</p>
<pre class="crayon-plain-tag">navigator.getUserMedia( { audio: true }, function ( stream ) {
    var audioContext = new AudioContext();

    // 从捕获的音频流创建一个媒体源管理
    var streamSource = audioContext.createMediaStreamSource( stream );

    // 把媒体源连接到目标（默认是扬声器）
    streamSource.connect( audioContext.destination );
}, error );</pre>
<div class="blog_h3"><span class="graybg">MediaStream</span></div>
<p>MediaStream对象提供以下方法：</p>
<ol>
<li>getAudioTracks()，音轨列表</li>
<li>getVideoTracks()，视轨列表</li>
</ol>
<p>每个音轨、视轨都有个label属性，对应其设备名称。</p>
<div class="blog_h3"><span class="graybg">Camera.js</span></div>
<p><a href="https://github.com/idevelop/camera.js">Camera.js</a>是对getUserMedia的简单封装，简化了API并提供了跨浏览器支持：</p>
<pre class="crayon-plain-tag">camera.init( {
    width: 640,
    height: 480,
    fps: 30, // 帧率
    mirror: false,  // 是否显示为镜像
    targetCanvas: document.getElementById( 'webcam' ), // 默认null，如果设置了则在画布中渲染

    onFrame: function ( canvas ) {
        // 每当新的帧被捕获，调用此回调
    },

    onSuccess: function () {
        // 流成功获取后
    },

    onError: function ( error ) {
        // 如果初始化失败
    },

    onNotSupported: function () {
        // 当浏览器不支持camera.js时
    }
} );
// 暂停
camera.pause();
// 恢复
camera.start();</pre>
<p><a href="https://idevelop.ro/predator-vision/">掠食者视觉</a>是基于Camera实现的一个好玩的例子（移动侦测）。</p>
<div class="blog_h2"><span class="graybg">信号处理</span></div>
<p>在端点之间（Peer）发送流之前，需要进行通信协调、发送控制消息，即所谓信号处理（Signaling），信号处理牵涉到三类信息：</p>
<ol>
<li>会话控制信息：初始化、关闭通信，报告错误</li>
<li>网络配置：对于其它端点来说，本机的IP和端口是什么</li>
<li>媒体特性：本机能够处理什么音视频编码、多高的分辨率。本机发送什么样的音视频编码</li>
</ol>
<p>WebRTC没有对信号处理规定太多，我们可以通过Ajax/WebSocket通信，以SIP、Jingle、ISUP等协议完成信号处理。点对点连接设立后，流的传输并不需要服务器介入。信号处理的示意图如下：</p>
<p><img class="aligncenter wp-image-15587 size-full" src="https://blog.gmem.cc/wp-content/uploads/2017/08/jsep.png" alt="jsep" width="100%" /></p>
<div class="blog_h3"><span class="graybg">示例代码</span></div>
<p>下面的代表片段包含了一个视频电话的信号处理过程：</p>
<pre class="crayon-plain-tag">// 信号处理通道，底层传输方式和协议自定义
var signalingChannel = createSignalingChannel();
var conn;

// 信号通过此回调送达本地，可能分多次送达
signalingChannel.onmessage = function ( evt ) {
    if ( !conn ) start( false );

    var signal = JSON.parse( evt.data );
    // 会话描述协议（Session Description Protocol），用于交换媒体配置信息（分辨率、编解码能力）
    if ( signal.sdp )
    // 设置Peer的RTCSessionDescription
        conn.setRemoteDescription( new RTCSessionDescription( signal.sdp ) );
    else
    // 添加Peer的Candidate信息
        conn.addIceCandidate( new RTCIceCandidate( signal.candidate ) );
};

// 调用此方法启动WebRTC，获取本地流并显示，侦听连接上的事件并处理
function start( isCaller ) {
    conn = new RTCPeerConnection( { /**/ } );

    // 把地址/端口信息发送给其它Peer。所谓Candidate就是基于ICE框架获得的本机可用地址/端口
    conn.onicecandidate = function ( evt ) {
        signalingChannel.send( JSON.stringify( { "candidate": evt.candidate } ) );
    };

    // 当远程流到达后，在remoteView元素中显示
    conn.onaddstream = function ( evt ) {
        remoteView.src = URL.createObjectURL( evt.stream );
    };

    // 获得本地流
    navigator.getUserMedia( { "audio": true, "video": true }, function ( stream ) {
        // 在remoteView元素中显示
        localView.src = URL.createObjectURL( stream );
        // 添加本地流，Peer将接收到onaddstream事件
        conn.addStream( stream );


        if ( isCaller )
        // 获得本地的RTCSessionDescription
            conn.createOffer( gotDescription );
        else
        // 针对Peer的RTCSessionDescription生成兼容的本地SDP
            conn.createAnswer( conn.remoteDescription, gotDescription );

        function gotDescription( desc ) {
            // 设置自己的RTCSessionDescription
            conn.setLocalDescription( desc );
            // 把自己的RTCSessionDescription发送给Peer
            signalingChannel.send( JSON.stringify( { "sdp": desc } ) );
        }
    } );
}

// 通信发起方调用：
start( true );</pre>
<div class="blog_h2"><span class="graybg">流转发</span></div>
<p>主要牵涉到的接口是RTCPeerConnection，上面的例子中已经包含了此接口的用法。WebRTC在底层做很多复杂的工作，这些工作对于JavaScript来说是透明的： </p>
<ol>
<li>执行解码</li>
<li>屏蔽丢包的影响</li>
<li>点对点通信：WebRTC引入流交互式连接建立（Interactive Connectivity Establishment，ICE）框架。ICE负责建立点对点链路的建立：
<ol>
<li>首先尝试直接</li>
<li>不行的话尝试STUN（Session Traversal Utilities for NAT）协议。此协议通过一个简单的保活机制确保NAT端口映射在会话期间有效</li>
<li>仍然不行尝试TURN（Traversal Using Relays around NAT）协议。此协议依赖于部署在公网上的中继服务器。只要端点可以访问TURN服务器就可以建立连接</li>
</ol>
</li>
<li>通信安全</li>
<li>带宽适配</li>
<li>噪声抑制</li>
<li>动态抖动缓冲（dynamic jitter buffering），抖动是由于网络状况的变化，缓冲用于收集、存储数据，定期发送</li>
</ol>
<div class="blog_h2"><span class="graybg">任意数据交换</span></div>
<p>通过RTCDataChannel完成，允许点对点之间任意的数据交换。RTCPeerConnection连接创建后，不但可以传输音视频流，还可以打开多个信道（RTCDataChannel）进行任意数据的交换。RTCDataChanel的特点是：</p>
<ol>
<li>类似于WebSocket的API</li>
<li>支持带优先级的多通道</li>
<li>超低延迟，因为不需要通过服务器中转</li>
<li>支持可靠/不可靠传输语义。支持SCTP、DTLS、UDP几种传输协议</li>
<li>内置安全传输（DTLS）</li>
<li>内置拥塞控制</li>
</ol>
<p>使用RTCDataChannel可以很好的支持游戏、远程桌面、实时文本聊天、文件传输、去中心化网络等业务场景。</p>
<div class="blog_h2"><span class="graybg">adapter.js</span></div>
<p><a href="https://github.com/webrtc/adapter">WebRTC adapter</a>是一个垫片库，使用它开发WebRTC应用时，不需要考虑不同浏览器厂商的<a href="https://webrtc.org/web-apis/interop/">API前缀差异</a>。</p>
<div class="blog_h2"><span class="graybg">WebRTC示例</span></div>
<p>本节列出一些WebRTC的代码示例，这些例子都使用adapter.js。</p>
<div class="blog_h3"><span class="graybg">限定分辨率</span></div>
<pre class="crayon-plain-tag">// 指定分辨率
// adapter.js 支持Promise
navigator.mediaDevices.getUserMedia( { video: { width: { exact: 640 }, height: { exact: 480 } } } ).then( stream =&gt; {
    let video = document.createElement( 'video' );
    document.body.appendChild( video );
    video.srcObject = stream;
    video.play();
} ).catch( err =&gt; console.log( err ) );</pre>
<div class="blog_h3"><span class="graybg">在画布中截图</span></div>
<pre class="crayon-plain-tag">// video为video元素
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);</pre>
<div class="blog_h2"><span class="graybg">WebRTC框架</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">框架</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td><a href="http://peerjs.com/">PeerJS </a></td>
<td>
<p>简化WebRTC的点对点通信、视频、音频调用</p>
<p>提供云端的PeerServer，你也可以自己搭建服务器</p>
</td>
</tr>
<tr>
<td><a href="https://github.com/peer5/sharefest">Sharefest</a></td>
<td>基于Web的P2P文件共享</td>
</tr>
<tr>
<td><a href="https://github.com/webRTC-io/webRTC.io">webRTC.io</a></td>
<td>
<p>WebRTC的一个抽象层，同时提供了客户端、服务器端Node.js组件。服务器端组件抽象了STUN</p>
<p>类似的框架还有<a href="https://github.com/andyet/SimpleWebRTC">SimpleWebRTC</a>、<a href="https://github.com/priologic/easyrtc">easyrtc</a></p>
</td>
</tr>
<tr>
<td><a href="https://www.openwebrtc.org/">OpenWebRTC</a></td>
<td>
<p>允许你构建能够和遵循WebRTC标准的浏览器进行通信的Native应用程序，支持Java绑定</p>
</td>
</tr>
<tr>
<td><a href="https://nextrtc.org/">NextRTC</a></td>
<td>
<p>基于Java实现的WebRTC信号处理服务器</p>
</td>
</tr>
<tr>
<td><a href="https://github.com/meetecho/janus-gateway">Janus</a></td>
<td>
<p>这是一个WebRTC网关，纯服务器端组件，目前仅仅支持Linux环境下安装</p>
<p>Janus本身实现了到浏览器的WebRTC连接机制，支持以JSON格式交换数据，支持在服务器端应用逻辑 - 浏览器之间中继RTP/RTCP和消息。特殊化的功能有服务器端插件完成</p>
<p>官网地址：<a href="https://janus.conf.meetecho.com/index.html">https://janus.conf.meetecho.com</a></p>
</td>
</tr>
<tr>
<td><a href="https://www.kurento.org">Kurento</a></td>
<td>
<p>这是一个开源的WebRTC媒体服务器</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">备选方案一：从RTSP开始</span></div>
<p>我们首先尝试的方案是直接使用RTSP源，原因是海康、大华主流厂商的较新的IP摄像头均支持暴露标准化的RTSP流。</p>
<div class="blog_h2"><span class="graybg">尝试播放</span></div>
<p>使用VLC播放器，打开网络串流：rtsp://admin:12345@192.168.0.196:554/ch1/main/av_stream，视频源为公司门口的海康摄像头的主码流（main，子码流为sub）。</p>
<p>发现可以正常播放，说明视频格式应该是标准的。VLC菜单 Tool ⇨ Codec Info查看，编码格式为H264。</p>
<p>浏览器无法直接使用RTSP协议，因此，需要有服务器端来处理视频源的RTSP，将其转换为：</p>
<ol>
<li>通过WebSocket发送的视频片断，由客户端的：
<ol>
<li>JSMpeg/Broadway直接解码，渲染到画布</li>
<li>或者，构造MP4片断Feed给MSE播放</li>
</ol>
</li>
<li>或者，通过WebRTC网关，转换后提供给客户端的WebRTC代码处理</li>
<li>或者，使用浏览器插件机制，例如Chrome的NaCl</li>
</ol>
<div class="blog_h2"><span class="graybg">实现方式一：MSE</span></div>
<p>Streamedian的服务器端需要授权，我们选用了另外一个实现。</p>
<p><span style="color: #24292e;"><a href="https://github.com/veyesys/h5stream">H5S</a>是一个基于live555实现的开源的HTML5 RTSP网关，支持将RTSP/H264流输入转换为HTML5 MSE支持的H264，客户端基于MSE。</span></p>
<div class="blog_h3"><span class="graybg">服务器</span></div>
<p>尝试在容器中运行H5S：</p>
<pre class="crayon-plain-tag">docker create --name ubuntu-16.04 -h ubuntu-16 --network local --dns 172.21.0.1 --ip 172.21.0.6 -it docker.gmem.cc/ubuntu:16.04 bash
docker start ubuntu-16.04
docker exec -it ubuntu-16.04 bash

apt update &amp;&amp; apt install wget
wget https://raw.githubusercontent.com/veyesys/release/master/h5stream/H5S-r1.0.1128.16-Ubuntu-16.04-64bit.tar.gz
tar xzf H5S-r1.0.1128.16-Ubuntu-16.04-64bit.tar.gz &amp;&amp; mv H5S-r1.0.1128.16-Ubuntu-16.04-64bit h5s-1.0

cd h5s-1.0
export LD_LIBRARY_PATH=`pwd`/lib/:$LD_LIBRARY_PATH
# 指定两次密码，可能H5S存在bug，不这样报身份验证失败
./h5ss rtsp://admin:12345@192.168.0.196:554/ch1/sub/av_stream admin 12345</pre>
<div class="blog_h3"><span class="graybg">客户端</span></div>
<p>使用H5S自带的基于MSE的客户端代码 + Chrome 49，播放后发现画面静止。控制它查看发现解码错误。打开chrome://media-internals/，发现错误Media segment did not begin with key frame. Support for such segments will be available in a future version。看样子是提供给SourceBuffer的数据不是以关键帧开始导致，未来版本的Chrome可能取消此限制。</p>
<p>换成Chrome 50，可以正常播放，但是流畅度较差，播放一段时间后出现卡死的情况。</p>
<div class="blog_h3"><span class="graybg">小结</span></div>
<p>H5S实现不完善，在不修改源码的情况下，服务器端只能接入一路视频输入。客户端也存在不流畅、卡死的问题，不适合生产环境。</p>
<div class="blog_h2"><span class="graybg">实现方式二：JSMpeg</span></div>
<div class="blog_h3"><span class="graybg">转码进程</span></div>
<p>在上文中我们已经成功尝试了利用JSMpege + WebSocket的方式，在网页中显示摄像头捕获的视频。ffmpeg转换RTSP也是非常简单的：</p>
<pre class="crayon-plain-tag">ffmpeg -i rtsp://admin:12345@192.168.0.196:554/ch1/main/av_stream -s 427x240 -f mpegts -vcodec mpeg1video -b 800k -r 30 http://127.0.0.1:8800/12345</pre>
<div class="blog_h3"><span class="graybg">服务器</span></div>
<p>可以使用JSMpeg自带的简单Node.js服务器测试：</p>
<pre class="crayon-plain-tag">node ./app/websocket-relay.js 12345 8800 8801 </pre>
<div class="blog_h3"><span class="graybg">客户端</span></div>
<p>下面是客户端代码，默认JSMpeg会基于WebGL渲染，但是我的机器最多开到8画面，开9画面时出现警告：</p>
<p>Too many active WebGL contexts. Oldest context will be lost，且第一画面丢失，简单的通融方法是，第9画面使用Canvas2D渲染：</p>
<pre class="crayon-plain-tag">new JSMpeg.Player( 'ws://127.0.0.1:8801/', {
    canvas: document.getElementById( 'canvas9' ),
    autoplay: true,
    // 浏览器对WebGL context的数量有限制
    disableGl: true
} ); </pre>
<p>渲染截图：</p>
<p><img class="aligncenter size-large wp-image-15669" src="https://blog.gmem.cc/wp-content/uploads/2017/08/jsmpeg-s9-1024x621.png" alt="jsmpeg-s9" width="100%" /></p>
<div class="blog_h3"><span class="graybg">小结</span></div>
<p>这种方式客户端解码压力较大，同时开9画面的352x288视频，我的机器上CPU占用率大概到40%左右，画面变化较为剧烈的时候会出现卡顿现象。</p>
<div class="blog_h2"><span class="graybg">实现方式三：Broadway</span></div>
<p>与JSMpeg类似，Broadway也是JavaScript解码工具。关键之处是，Broadway支持的视频编码是H.264，意味着可能免去消耗服务器资源的视频重编码。</p>
<p><a id="hk-av-config"></a>最初的尝试并不顺利，根据IP摄像头的RTSP Describe应答（SDP），我们推断其H.264 Profile为Baseline，但是不转码的情况下Broadway根本无法播放。后来查看ffmpeg的日志输出，发现其实际上使用的Profile是Main。进一步尝试，发现摄像头是可以配置为Baseline的：</p>
<p><img class="aligncenter size-full wp-image-16196" src="https://blog.gmem.cc/wp-content/uploads/2017/08/hk-config.png" alt="hk-config" width="707" height="465" /></p>
<p>只需要把编码复杂度设置为低，H.264的Profile就从Main变为Baseline。</p>
<p>设置完毕后，仍然基于h264-live-player的Demo进行测试，使用如下命令行抽取原始H.264帧：</p>
<pre class="crayon-plain-tag">ffmpeg -i rtsp://admin:12345@192.168.0.196:554/ch1/main/av_stream -c:v copy -f rawvideo  -</pre>
<p>即可免转码的进行实时视频预览了。 </p>
<p>此实现方式更多细节信息请参考<a href="/html5-vs-with-broadway">基于Broadway的HTML5视频监控</a>。</p>
<div class="blog_h2"><span class="graybg">实现方式四：NaCl</span></div>
<p>Chrome放弃NPAPI之后，插件开发需要使用PPAPI /NaCl。目前能找到的实现有<a href="https://www.videoexpertsgroup.com/vxg-chrome-plugin/">VXG Chrome Plugin</a>，这是一个商业产品，需要授权。除了RTSP之外，还支持RTMP、HLS等协议。</p>
<p>插件方案的缺点是，需要安装，而且仅仅针对单种浏览器。优势则是灵活性高，理论上性能可以做的很好。</p>
<div class="blog_h2"><span class="graybg">实现方式五：WebRTC</span></div>
<p>WebRTC相关的框架非常多，经过简单的比较，我们决定从Kurento入手。主要原因是：</p>
<ol>
<li>容易扩展的模块化设计</li>
<li>提供Java客户端、JS客户端</li>
<li>可以在服务器端合成多画面，这样可以减轻客户端解码压力，特别是那些低配置的客户端</li>
<li>内置对RTSP协议的支持</li>
</ol>
<p><a href="/webrtc-server-basedon-kurento#vs">基于Kurento搭建WebRTC服务器</a>一文详细讨论了这种实现方式。</p>
<div class="blog_h1"><span class="graybg">备选方案二：从设备SDK开始</span></div>
<p>这里的设备，主要包括：网络硬盘录像机（NVR）、视频服务器、IP摄像头。为了便于二次开发，硬件厂商都为这些设备配置的相应的SDK套件。这些SDK通常都提供了：实时码流预览、录像文件回放、播放控制（如：暂停、单帧前进、单帧后退）、获取码流基本信息、播放截图等功能。</p>
<p>我们的基本目标是，通过SDK得到标准化的码流，例如H264格式。具体如何操作，得看厂商的SDK，但是思路基本是：</p>
<ol>
<li>如果SDK直接支持获取标准格式的流，例如RTSP，那么备选方案一就可以直接用上</li>
<li>如果SDK支持获取标准编码的视频帧，例如H264，那我们只需要将其包装为合适的容器格式，再通过RTSP/HTTP的方式发送出去</li>
<li>如果SDK支持获取解码后的原始图像数据，例如RGB、YV12，我们可以基于H264再次编码，然后按第2步方式处理。这种方式对服务器性能要求比较高，CPU压力较大，PC机处理不了多少个通道</li>
<li>如果都不支持，只提供了封装好的播放控件 —— 这个就比较悲催了，不过通过OS底层API，例如Windows的GDI应该也是可以实现，否则那些屏幕录像软件怎么做的呢？</li>
</ol>
<div class="blog_h2"><span class="graybg">海康SDK</span></div>
<p>根据Linux版本的海康设备网络编程指南的描述，我们应该可以：</p>
<ol>
<li>调用NET_DVR_Init进行SDK初始化</li>
<li>调用NET_DVR_Login登陆到目标设备</li>
<li>调用NET_DVR_RealPlay进行播放，此时返回一个实时播放句柄
<ol>
<li>如果设备支持RTSP协议取流：针对上述句柄调用NET_DVR_SetStandardDataCallBack，可以设置一个标准的数据回调函数，此回调会接受到标准码流，这对应上面的第1种思路</li>
<li>如果设备不支持RTSP协议取流：针对上述句柄调用NET_DVR_SetRealDataCallBack，然后通过PlayM4播放库中的PlayM4_SetDecCallBack回调得到<a href="/image-processing-faq#yv12">yv12</a>格式的原始图像。这对应上面的第3种思路</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">示例代码</span></div>
<p>cmake构建配置：</p>
<pre class="crayon-plain-tag">cmake_minimum_required(VERSION 3.6)
project(hikvision)

include_directories(/home/alex/CPP/lib/hcnedsdk/include)

set(SOURCE_FILES getstream.cpp)
add_executable(getstream ${SOURCE_FILES})
target_link_libraries(getstream /home/alex/CPP/lib/hcnedsdk/lib/libhcnetsdk.so)</pre>
<p> C++代码：</p>
<pre class="crayon-plain-tag">#include &lt;HCNetSDK.h&gt;
#include &lt;stdio.h&gt;
#include &lt;cstring&gt;
#include &lt;unistd.h&gt;

// RTSP协议取流
void CALLBACK cbStdData( LONG lRealHandle, DWORD dwDataType, BYTE *pBuffer, DWORD dwBufSize, DWORD dwUser ) {
    switch ( dwDataType ) {
        case NET_DVR_SYSHEAD:        // 系统头数据，回调的第一个包是40字节的文件头
            break;
        case NET_DVR_STREAMDATA:     // 基于私有协议时：视频流数据（包括复合流和音视频分开的视频流数据）
            break;
        case NET_DVR_STD_VIDEODATA:  // 基于标准协议时：标准视频流数据（RTP包）
            break;
        case NET_DVR_STD_AUDIODATA:  // 基于标准协议时：标准音频流数据
            break;
        case NET_DVR_SDP:            // SDP信息(RTSP传输时有效)
            break;
        case NET_DVR_PRIVATE_DATA:   // 私有数据,包括智能信息叠加等
            break;
    }
}

int main() {
    // SDK初始化
    BOOL result = NET_DVR_Init();
    if ( !result ) return 1;

    // 同步登陆
    NET_DVR_USER_LOGIN_INFO struLoginInfo = { 0 };
    struLoginInfo.bUseAsynLogin = 0;
    strcpy( struLoginInfo.sDeviceAddress, "192.168.0.196" );
    struLoginInfo.wPort = 8000;
    strcpy( struLoginInfo.sUserName, "admin" );
    strcpy( struLoginInfo.sPassword, "12345" );
    NET_DVR_DEVICEINFO_V40 struDevInfo = { 0 };
    LPNET_DVR_DEVICEINFO_V30 lpDevInfo30;
    long lUserID = NET_DVR_Login_V40( &amp;struLoginInfo, &amp;struDevInfo );
    if ( lUserID &lt; 0 ) {
        printf( "登陆失败，错误码 %d\n", NET_DVR_GetLastError());
        NET_DVR_Cleanup();
        return 1;
    } else {
        lpDevInfo30 = &amp;struDevInfo.struDeviceV30;
        printf( "成功登陆到设备：%s\n", lpDevInfo30-&gt;sSerialNumber );
        printf( "SDK字符串编码方式（1 GB2312，2 GBK，3 BIG5，6 UTF-8）：%d\n", struDevInfo.byCharEncodeType );
        printf( "设备类型（31 高清网络摄像机）：%d\n", lpDevInfo30-&gt;wDevType );
        printf( "模拟通道起始号：%d，模拟通道个数%d，数字通道起始号：%d，数字通道个数%d\n", lpDevInfo30-&gt;byStartChan, lpDevInfo30-&gt;byChanNum,
                lpDevInfo30-&gt;byStartDChan, lpDevInfo30-&gt;byIPChanNum + lpDevInfo30-&gt;byHighDChanNum &lt;&lt; 8 );
        printf( "主码流是否支持RTSP方式：%s，子码流是否支持RTSP方式：%s\n", lpDevInfo30-&gt;byMainProto &gt; 0 ? "是" : "否",
                lpDevInfo30-&gt;bySubProto &gt; 0 ? "是" : "否" );
    }

    // 启动预览
    NET_DVR_PREVIEWINFO struPrevInfo = { 0 };
    struPrevInfo.hPlayWnd = NULL;    // Linux 64 位系统不支持软解码功能
    struPrevInfo.lChannel = 1;       // 预览通道号
    struPrevInfo.dwStreamType = 0;   // 0-主码流， 1-子码流， 2-码流 3， 3-码流 4，以此类推
    struPrevInfo.dwLinkMode = 0;     // 0- TCP 方式， 1- UDP 方式， 2- 组播方式， 3- RTP 方式， 4-RTP/RTSP， 5-RSTP/HTTP
    struPrevInfo.bBlocked = 1;       // 0- 非阻塞取流， 1- 阻塞取流
    struPrevInfo.byProtoType = 1;    // 应用层取流协议使用RTSP
    LONG lRealHandle = NET_DVR_RealPlay_V40( lUserID, &amp;struPrevInfo, NULL, NULL );
    if ( lRealHandle == -1 ) {
        printf( "启动预览失败，错误码 %d\n", NET_DVR_GetLastError());
        NET_DVR_Logout( lUserID );
        NET_DVR_Cleanup();
        return 1;
    }

    if ( lpDevInfo30-&gt;byMainProto ) {
        printf( "设置获取标准码流的回调\n" );
        // 仅支持对 支持RTSP协议取流的设备的 标准码流回调
        NET_DVR_SetStandardDataCallBack( lRealHandle, cbStdData, NULL );
    }

    sleep( 120 );
    // 停止预览
    NET_DVR_StopRealPlay( lRealHandle );
    // 登出
    NET_DVR_Logout( lUserID );
    // SDK清理
    NET_DVR_Cleanup();
    return 0;
}</pre>
<p>运行脚本：</p>
<pre class="crayon-plain-tag">export HKLIB_HOME=/home/alex/CPP/lib/hcnedsdk/lib
export LD_LIBRARY_PATH=$HKLIB_HOME:$HKLIB_HOME/HCNetSDKCom
./getstream </pre>
<p>此程序运行后，会自动获取到基于RTSP协议的媒体流，回调函数会反复被调用：</p>
<ol>
<li>第一次调用为40字节的头，不太清楚有什么用</li>
<li>第二次调用传递了SDP</li>
<li>后续调用传递标准音视频数据，其内容是RTP封包</li>
</ol>
<div class="blog_h1"><span class="graybg">总结</span></div>
<p>基于HTM5的视频监控，媒体流从采集设备到浏览器，主要路径如下图所示：</p>
<p><img class="aligncenter size-full wp-image-15824" src="https://blog.gmem.cc/wp-content/uploads/2017/08/h5vs-dataflow.png" alt="h5vs-dataflow" width="100%" /></p>
<p>对上图的说明如下：</p>
<ol>
<li>在设备层，需要以某种方式获得码流，以流协议的方式发送出去。最常用的方式是RTSP/RTP。流的可能获取路径为：
<ol>
<li>设备直接暴露RTSP协议端点，并且发送标准码流</li>
<li>设备SDK允许获取标准码流，需要自己以RTSP协议发送</li>
<li>设备SDK允许获得解码后的逐帧，需要直接编码为H264，然后以RTSP发送</li>
</ol>
</li>
<li>流媒体层通常需要引入专门的流媒体服务器，这类服务器能够在内部进行各种流协议的转换，可以解除客户端对特定流协议的依赖</li>
<li>客户端和服务器端的传输方式，可以有TCP、HTTP、P2P（WebRTC）、WebSocket等多种。其中
<ol>
<li>直接的TCP协议浏览器是不支持的，这意味着RTSP/RTMP等协议，在浏览器端必须要有插件才可以使用</li>
<li>WebSocket通常配合JSMpeg或者MSE使用，由程序向JSMpeg/MSE不断Feed视频帧</li>
</ol>
</li>
<li>客户端解码展示的技术主要有三类：
<ol>
<li>浏览器内置的解码能力，主要通过video标签，MSE属于此类</li>
<li>JavaScript软解码，主要是JSMpeg、Broadway</li>
<li>插件机制，例如Chrome的NaCl</li>
</ol>
</li>
</ol>
<p>能够免于引入流媒体层的方案，需要：设备能直接暴露标准码流的RTSP端点，并且安装浏览器插件。缺点也很明显，一个是设备的访问密码暴露给了客户端，第二个是目前没有成熟、开源的插件可用。我相信主要原因是合理技术方向不在于此，没人愿意去开发。</p>
<p>直接使用设备层的RTSP端点，可能存在兼容性问题。一个是它发送的码流是否标准化，第二个是市场上有多少设备没有暴露RTSP端点。</p>
<p>客户端方面，JSMpeg是兼容性较好的方案，WebRTC/MSE都有部分平台不支持（但是桌面级的浏览器大部分支持）。JSMpeg的缺点是：</p>
<ol>
<li>如果基于WebGL渲染，受限于浏览器WebGL上下文最大数量，多画面可能无法渲染。某些流媒体服务器支持在服务器端合成多画面Grid，可以规避此缺点</li>
<li>如果基于Canvas2D渲染，画质较差（我的机器上还有莫名其妙的斜线）</li>
<li>对码流格式要求严格，仅仅支持MPEG-TS，此格式压缩比差，网络带宽占用大</li>
<li>性能相对较差，尽管使用了MPEG-TS这种简单的视频格式，基于JavaScript解码渲染仍然使客户端压力较大。我的机器（i7-4940MX / Quadro K5100M / Ubuntu 14.04 LTS）上会出现卡顿情况</li>
</ol>
<p>和JSMpeg类似的库是Broadway，后者能够进行Baseline的H.264解码。如果设备支持Baseline H.264输出，使用Broadway可以很好的解决服务器端转码导致的资源消耗问题。</p>
<div class="blog_h1"><span class="graybg">附录</span></div>
<div class="blog_h2"><span class="graybg">参考资料</span></div>
<ol>
<li><a href="https://developer.mozilla.org/en-US/Apps/Fundamentals/Audio_and_video_delivery">Audio and Video Delivery</a></li>
<li><a href="https://www.w3.org/TR/media-source/">W3C Recommendation - Media Source Extensions™</a></li>
<li><a href="https://webrtc.org">WebRTC Project Home</a></li>
<li><a href="https://imququ.com/post/html5-live-player-3.html">HTML5 视频直播（三）</a></li>
</ol>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/research-on-html5-video-surveillance">HTML5视频监控技术预研</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/research-on-html5-video-surveillance/feed</wfw:commentRss>
		<slash:comments>7</slash:comments>
		</item>
	</channel>
</rss>
