欢迎光临
我们一直在努力

私有化部署WebRTC:基于aiortc实现Web浏览器直接预览远程摄像头

私有化部署WebRTC:基于aiortc实现Web浏览器直接预览远程摄像头

    • 引言:为什么需要这个方案?
    • 一、WebRTC与aiortc:技术基础
      • 什么是WebRTC?
      • 为什么选择aiortc?
    • 二、系统架构设计
      • 组件说明:
    • 三、详细部署步骤
      • 步骤1:环境准备
      • 步骤2:部署STUN/TURN服务器
        • 2.1 安装coturn
        • 2.2 配置防火墙
        • 2.3 测试STUN/TURN服务
      • 步骤3:部署信令服务器
        • 3.1 创建信令服务器脚本
        • 3.2 安装依赖并运行
      • 步骤4:部署Web服务器
        • 4.1 创建Web服务器
        • 4.2 创建JavaScript客户端
        • 4.3 设置环境变量并运行
      • 步骤5:部署设备端程序
        • 5.1 创建设备端Python程序
        • 5.2 运行设备端
      • 步骤6:Web预览

引言:为什么需要这个方案?

在现代物联网和安防监控领域,远程查看摄像头视频流是一个常见需求。传统的解决方案通常采用RTSP协议,但这种方案存在一个致命缺陷:现代Web浏览器(如Chrome、Firefox)无法直接播放RTSP流。

之前我尝试了以下方案:

  • 第三方P2P穿透方案:仅支持Windows/Linux Qt客户端,不支持Web浏览器
  • EasyRTC等现成方案:无法私有化部署,稳定性欠佳
  • 直接使用libwebrtc:编译复杂,依赖庞大

今天,我介绍一个基于aiortc的简化方案,它能够:

  • ✅ 在Web浏览器中直接预览远程摄像头
  • ✅ 完全私有化部署,数据不经过第三方
  • ✅ 支持P2P直连、STUN穿透和TURN中转多种连接方式
  • ✅ 跨平台支持(Windows、Linux、Mac)
  • ✅ 代码简洁,易于理解和定制
  • 一、WebRTC与aiortc:技术基础

    什么是WebRTC?

    WebRTC(Web实时通信)是一个开源项目,允许Web浏览器和移动应用进行实时音视频通信和数据传输,而无需安装任何插件。它的核心优势在于:

  • 点对点通信:数据直接在两个客户端之间传输
  • 低延迟:专为实时通信设计
  • 浏览器原生支持:现代浏览器都内置WebRTC API
  • 自动NAT穿透:通过STUN/TURN技术解决网络障碍
  • 为什么选择aiortc?

    aiortc是一个基于Python asyncio的WebRTC实现库,相比原生libwebrtc,它有如下优势:

    特性
    aiortc
    libwebrtc
    安装复杂度 pip一键安装 需要复杂编译
    语言友好性 Python,易于集成 C++,集成复杂
    代码简洁性 高级API,代码简洁 低级API,代码冗长
    灵活性 易于自定义和扩展 修改困难

    二、系统架构设计

    我们的解决方案包含四个核心组件:

    #mermaid-svg-s2abkVpUFa8EHZ3G{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s2abkVpUFa8EHZ3G .error-icon{fill:#552222;}#mermaid-svg-s2abkVpUFa8EHZ3G .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s2abkVpUFa8EHZ3G .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G .marker.cross{stroke:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s2abkVpUFa8EHZ3G p{margin:0;}#mermaid-svg-s2abkVpUFa8EHZ3G .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster-label text{fill:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster-label span{color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster-label span p{background-color:transparent;}#mermaid-svg-s2abkVpUFa8EHZ3G .label text,#mermaid-svg-s2abkVpUFa8EHZ3G span{fill:#333;color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .node rect,#mermaid-svg-s2abkVpUFa8EHZ3G .node circle,#mermaid-svg-s2abkVpUFa8EHZ3G .node ellipse,#mermaid-svg-s2abkVpUFa8EHZ3G .node polygon,#mermaid-svg-s2abkVpUFa8EHZ3G .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .rough-node .label text,#mermaid-svg-s2abkVpUFa8EHZ3G .node .label text,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape .label,#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape .label{text-anchor:middle;}#mermaid-svg-s2abkVpUFa8EHZ3G .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .rough-node .label,#mermaid-svg-s2abkVpUFa8EHZ3G .node .label,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape .label,#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape .label{text-align:center;}#mermaid-svg-s2abkVpUFa8EHZ3G .node.clickable{cursor:pointer;}#mermaid-svg-s2abkVpUFa8EHZ3G .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G .arrowheadPath{fill:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s2abkVpUFa8EHZ3G .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s2abkVpUFa8EHZ3G .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s2abkVpUFa8EHZ3G .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s2abkVpUFa8EHZ3G .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s2abkVpUFa8EHZ3G .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster text{fill:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster span{color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s2abkVpUFa8EHZ3G .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G rect.text{fill:none;stroke-width:0;}#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape p,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape rect,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s2abkVpUFa8EHZ3G .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s2abkVpUFa8EHZ3G .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s2abkVpUFa8EHZ3G :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    视频流

    信令交换

    ICE协商

    SDP/ICE转发

    SDP/ICE转发

    远程设备

    WebRTC PeerConnection

    Web浏览器

    信令服务器

    STUN/TURN服务器

    组件说明:

  • STUN/TURN服务器(coturn)

    • STUN:帮助设备发现自己的公网IP和端口
    • TURN:当P2P无法建立时,作为数据中转服务器
  • 信令服务器(Python WebSocket)

    • 交换WebRTC连接所需的SDP(会话描述协议)和ICE候选地址
    • 管理设备注册和发现
  • Web服务器(Python HTTP)

    • 提供Web页面给用户访问
    • 集成WebRTC JavaScript客户端
  • 设备端(Python aiortc)

    • 捕获视频流(摄像头、文件、虚拟视频)
    • 通过WebRTC传输视频到Web端
  • 三、详细部署步骤

    步骤1:环境准备

    在开始之前,请确保你有一台具有公网IP的云服务器(如阿里云ECS)。我们将在这台服务器上部署所有服务。

    首先设置环境变量(这些变量将在后续脚本中使用):

    export ECS_IP_ADDR=替换为你的云服务器实际IP地址
    export STUN_SRV=$ECS_IP_ADDR
    export STUN_PORT=3478
    export SIGNALING_SRV=$ECS_IP_ADDR #信令服务器,用来交换WebRTC连接需要的信息
    export SIGNALING_PORT=9901
    export WEB_SRV=$ECS_IP_ADDR # Web服务器
    export WEB_PORT=9900

    重要提示:将这些变量添加到你的~/.bashrc文件中,以便每次登录时自动加载。

    步骤2:部署STUN/TURN服务器

    STUN/TURN服务器是WebRTC的\”网络向导\”,帮助两个设备找到彼此并建立连接。

    2.1 安装coturn

    # 在CentOS/RHEL系统上安装
    yum install coturn -y

    # 配置coturn
    cat > /etc/coturn/turnserver.conf << EOF
    listening-ip=0.0.0.0
    listening-port=$STUN_PORT
    relay-ip=$(ifconfig eth0 | grep -w inet | awk \'{print $2}\’)
    external-ip=$ECS_IP_ADDR
    log-file=/var/tmp/turn.log
    min-port=40000
    max-port=65535
    EOF

    systemctl restart coturn
    systemctl status coturn

    2.2 配置防火墙

    云服务器需要开放以下端口:

    • 3478:STUN/TURN服务端口
    • 9900:Web服务端口
    • 9901:信令服务器端口
    • 40000-65535:TURN中继端口范围

    在阿里云控制台的安全组中,添加入站规则允许这些端口的TCP和UDP流量。

    请添加图片描述

    2.3 测试STUN/TURN服务
    • 访问Trickle ICE 请添加图片描述

    stun:你的服务器IP:3478
    turn:你的服务器IP:3478

    使用用户名webrtc,密码webrtc。如果测试成功,你会看到类似以下结果:

    • srflx:STUN穿透成功
    • relay:TURN中转可用

    步骤3:部署信令服务器

    信令服务器是WebRTC连接的\”媒人\”,负责交换连接信息。它使用WebSocket协议进行实时通信。

    3.1 创建信令服务器脚本

    cat > signaling_server.py << EOF
    import asyncio
    import json
    import logging
    from aiohttp import web
    import aiohttp_cors

    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger(__name__)

    class SignalingServer:
    def __init__(self):
    self.app = web.Application()
    self.clients = {

    } # 存储连接的用户
    self.setup_routes()

    def setup_routes(self):
    cors = aiohttp_cors.setup(self.app, defaults={


    \”*\”: aiohttp_cors.ResourceOptions(
    allow_credentials=True,
    expose_headers=\”*\”,
    allow_headers=\”*\”,
    )
    })

    # 添加WebSocket路由
    resource = cors.add(self.app.router.add_resource(\’/ws\’))
    cors.add(resource.add_route(\”GET\”, self.websocket_handler))

    # 健康检查路由
    self.app.router.add_get(\’/health\’, self.health_check)

    # 获取在线设备列表
    self.app.router.add_get(\’/devices\’, self.get_devices)

    async def health_check(self, request):
    return web.Response(text=\’OK\’)

    async def get_devices(self, request):
    \”\”\”获取所有在线的设备\”\”\”
    devices = []
    for client_id, client_info in self.clients.items():
    if client_info[\’type\’] == \’device\’:
    devices.append({


    \’id\’: client_id,
    \’type\’: client_info[\’type\’]
    })

    return web.json_response({

    \’devices\’: devices})

    async def websocket_handler(self, request):
    ws = web.WebSocketResponse()
    await ws.prepare(request)

    client_id = None
    client_type = None

    try:
    async for msg in ws:
    if msg.type == web.WSMsgType.TEXT:
    try:
    data = json.loads(msg.data)
    message_type = data.get(\’type\’)

    if message_type == \’register\’:
    # 客户端注册
    client_id = data.get(\’client_id\’)
    client_type = data.get(\’client_type\’) # \’web\’ 或 \’device\’

    if client_id in self.clients:
    try:
    await self.clients[client_id][\’ws\’].close()
    except:
    pass

    self.clients[client_id] = {


    \’ws\’: ws,
    \’type\’: client_type,
    \’client_id\’: client_id
    }

    logger.info(f\”Client registered: {

    client_id} ({

    client_type})\”)
    await ws.send_str(json.dumps({


    \’type\’: \’registered\’,
    \’status\’: \’success\’
    }))

    elif message_type == \’offer\’:
    # 转发 offer 到对应的设备端
    target_device = data.get(\’target_device\’)
    offer_data = data.get(\’offer\’)

    if target_device in self.clients:
    await self.clients[target_device][\’ws\’].send_str(json.dumps({


    \’type\’: \’offer\’,
    \’offer\’: offer_data,
    \’from_client\’: client_id
    }))
    logger.info(f\”Offer forwarded from {

    client_id} to {

    target_device}\”)
    else:
    logger.warning(f\”Target device not found: {

    target_device}\”)
    await ws.send_str(json.dumps({


    \’type\’: \’error\’,
    \’message\’: f\’Device {

    target_device} not found\’
    }))

    elif message_type == \’answer\’:
    # 转发 answer 到对应的网页端
    target_web = data.get(\’target_web\’)
    answer_data = data.get(\’answer\’)

    if target_web in self.clients:
    await self.clients[target_web][\’ws\’].send_str(json.dumps({


    \’type\’: \’answer\’,
    \’answer\’: answer_data,
    \’from_client\’: client_id
    }))
    logger.info(f\”Answer forwarded from {

    client_id} to {

    target_web}\”)

    elif message_type == \’ice_candidate\’:
    # 转发 ICE candidate
    target = data.get(\’target\’)
    candidate = data.get(\’candidate\’)

    if target in self.clients:
    await self.clients[target][\’ws\’].send_str(json.dumps({


    \’type\’: \’ice_candidate\’,
    \’candidate\’: candidate,
    \’from_client\’: client_id
    }))

    elif message_type == \’ping\’:
    # 心跳检测
    await ws.send_str(json.dumps({

    \’type\’: \’pong\’}))

    except json.JSONDecodeError as e:
    logger.error(f\”JSON解析错误: {

    e}\”)

    elif msg.type == web.WSMsgType.ERROR:
    logger.error(f\’WebSocket error: {

    ws.exception()}\’)

    except Exception as e:
    logger.error(f\”WebSocket error: {

    e}\”)

    finally:
    # 清理客户端
    if client_id and client_id in self.clients:
    del self.clients[client_id]
    logger.info(f\”Client disconnected: {

    client_id}\”)

    return ws

    def main():
    server = SignalingServer()
    logger.info(\”Starting signaling server on http://0.0.0.0:$SIGNALING_PORT\”)
    web.run_app(server.app, host=\’0.0.0.0\’, port=$SIGNALING_PORT)

    if __name__ == \’__main__\’:
    main()
    EOF

    3.2 安装依赖并运行

    # 安装Python环境(如果使用conda)
    yum install conda -y
    conda create -n myenv python=3.12
    source ~/.bashrc
    conda activate myenv

    # 安装Python依赖
    pip install aiohttp
    pip install aiohttp_cors

    # 注意:脚本中的环境变量需要替换为实际值
    # 运行信令服务器
    python signaling_server.py

    步骤4:部署Web服务器

    Web服务器提供用户访问的界面,包含WebRTC JavaScript客户端。

    4.1 创建Web服务器

    cat > web_server.py << EOF
    from aiohttp import web
    import aiohttp_cors
    import logging

    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger(__name__)

    class WebServer:
    def __init__(self):
    self.app = web.Application()
    self.setup_routes()

    def setup_routes(self):
    cors = aiohttp_cors.setup(self.app, defaults={


    \”*\”: aiohttp_cors.ResourceOptions(
    allow_credentials=True,
    expose_headers=\”*\”,
    allow_headers=\”*\”,
    )
    })

    # 主页面
    self.app.router.add_get(\’/\’, self.index_handler)
    self.app.router.add_get(\’/index.html\’, self.index_handler)

    # 设备端页面
    self.app.router.add_get

    赞(0)
    未经允许不得转载:171主机测评 » 私有化部署WebRTC:基于aiortc实现Web浏览器直接预览远程摄像头
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址