python构建webRTC服务器,coturn搭建中继服务器

前端代码

html 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>WebRTC Camera to AIORTC</title>
</head>
<body>
    <video id="localVideo" autoplay muted playsinline></video>
    <video id="remoteVideo" autoplay playsinline></video>
    
    <button id="startBtn">start</button>
    <button id="callBtn" disabled>server</button>
    <button id="hangupBtn" disabled>stop</button>

    <script>
        const localVideo = document.getElementById('localVideo');
        const remoteVideo = document.getElementById('remoteVideo');
        const startBtn = document.getElementById('startBtn');
        const callBtn = document.getElementById('callBtn');
        const hangupBtn = document.getElementById('hangupBtn');

        let localStream;
        let pc;
        const configuration = {
            iceServers: [
                {
                    urls: "turn:公网IP地址:3478?transport=udp",
                    username: "user",
                    credential: "pass"
                }
            ]
        };

        // 启动摄像头
        startBtn.onclick = async () => {
            try {
                localStream = await navigator.mediaDevices.getUserMedia({ 
                    video: true, 
                    audio: false 
                });
                localVideo.srcObject = localStream;
                callBtn.disabled = false;
                startBtn.disabled = true;
            } catch (err) {
                console.error('获取摄像头失败:', err);
            }
        };

        // 连接到后端
        callBtn.onclick = async () => {
            callBtn.disabled = true;
            hangupBtn.disabled = false;

            pc = new RTCPeerConnection(configuration);

            // 添加本地流
            localStream.getTracks().forEach(track => {
                pc.addTrack(track, localStream);
            });

            // 处理远程流
            pc.ontrack = (event) => {
                console.log('接收到远程流');
                remoteVideo.srcObject = event.streams[0];
            };

            // ICE候选处理
            pc.onicecandidate = (event) => {
                if (event.candidate) {
                    // 发送ICE候选到后端
                    console.log('candidate:', event);
                    sendSignal({ type: 'ice-candidate', candidate: event.candidate });
                }
            };

            // 创建offer
            try {
                const offer = await pc.createOffer();
                await pc.setLocalDescription(offer);
                
                // 发送offer到后端
                console.log('offer:', event);
                sendSignal({ type: 'offer', sdp: offer.sdp ,cam_url:"webrtc",duration:10});
            } catch (err) {
                console.error('创建offer失败:', err);
            }
        };

        // 挂断
        hangupBtn.onclick = () => {
            if (pc) {
                pc.close();
                pc = null;
            }
            remoteVideo.srcObject = null;
            callBtn.disabled = false;
            hangupBtn.disabled = true;
        };

        // WebSocket信令
        let ws;
        function connectWebSocket() {
            // ip 使用 ws
            // 域名使用 wss
            ws = new WebSocket('wss://test.com/ws');
            
            ws.onopen = () => {
                console.log('WebSocket连接已建立');
            };

            ws.onmessage = async (event) => {
                const signal = JSON.parse(event.data);
                
                if (signal.type === 'answer') {
                    console.log('answer:', signal);
                    await pc.setRemoteDescription(new RTCSessionDescription({
                        type: 'answer',
                        sdp: signal.sdp
                    }));
                }
            };

            ws.onclose = () => {
                console.log('WebSocket连接已关闭');
            };
        }

        function sendSignal(signal) {
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify(signal));
            }
        }

        // 初始化WebSocket连接
        connectWebSocket();
    </script>
</body>
</html>

服务端代码

python 复制代码
# server.py
import asyncio
import json
import logging
from aiohttp import web, WSMsgType
from aiortc import (
    RTCPeerConnection,
    RTCSessionDescription,
    RTCIceCandidate,
    VideoStreamTrack,
    RTCConfiguration,
    RTCIceServer,
)
from aiortc.contrib.media import MediaBlackhole, MediaRecorder
import cv2
import numpy as np

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


class VideoProcessor(VideoStreamTrack):
    """
    自定义视频处理器,接收前端视频流并进行处理
    """

    kind = "video"

    def __init__(self, track):
        super().__init__()
        self.track = track
        self.frame_count = 0

    async def recv(self):
        try:
            frame = await self.track.recv()
            self.frame_count += 1

            # 将视频帧转换为OpenCV格式
            img = frame.to_ndarray(format="bgr24")

            # 在这里添加您的AI处理逻辑
            # 示例:简单的边缘检测
            processed_img = self.process_frame(img)

            # 将处理后的图像转换回视频帧
            processed_frame = frame.from_ndarray(processed_img, format="bgr24")
            processed_frame.pts = frame.pts
            processed_frame.time_base = frame.time_base

            if self.frame_count % 30 == 0:  # 每30帧打印一次
                logger.info(f"处理了第 {self.frame_count} 帧")
            return processed_frame
        except Exception as e:
            logger.error(f"处理视频帧时出错: {e}")
            raise

    def process_frame(self, img):
        """处理视频帧的示例函数"""
        try:
            # 转换为灰度图
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            # 边缘检测
            edges = cv2.Canny(gray, 100, 200)
            # 将边缘检测结果合并回BGR格式
            result = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
            return result
        except Exception as e:
            logger.error(f"处理帧时出错: {e}")
            return img


class WebRTCServer:
    def __init__(self):
        pass

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

        pc = None

        try:
            async for msg in ws:
                if msg.type == WSMsgType.TEXT:
                    data = json.loads(msg.data)
                    logger.info(f"接收到信令: {data['type']}")

                    if data["type"] == "offer":
                        # 处理offer
                        offer = RTCSessionDescription(sdp=data["sdp"], type="offer")
                        pc = RTCPeerConnection(
                            configuration=RTCConfiguration(
                                iceServers=[
                                    RTCIceServer(
                                        "turn:公网IP地址:3478?transport=udp",
                                        username="user",
                                        credential="pass",
                                    )
                                ]
                            )
                        )


                        @pc.on("track")
                        async def on_track(track):
                            logger.info(f"接收到轨道: {track.kind}")

                            if track.kind == "video":
                                logger.info("开始处理视频流...")
                                video_processor = VideoProcessor(track)
                                pc.addTrack(video_processor)
                            

                        # 设置远程描述
                        await pc.setRemoteDescription(offer)
                        logger.info("已设置远程描述")

                        # 创建answer
                        answer = await pc.createAnswer()
                        await pc.setLocalDescription(answer)
                        logger.info("已创建answer")

                        # 发送answer
                        await ws.send_json({"type": "answer", "sdp": pc.localDescription.sdp})
                        logger.info("已发送answer")

                    elif data["type"] == "ice-candidate" and pc:
                        # 修复ICE候选处理
                        try:
                            candidate_data = data["candidate"]
                            logger.info(f"处理ICE候选: {candidate_data}")

                            # 解析candidate字符串来获取参数
                            candidate_str = candidate_data.get("candidate", "")

                            # 从candidate字符串中解析信息
                            parts = candidate_str.split()
                            if len(parts) >= 8:
                                foundation = parts[0]
                                component = int(parts[1])
                                protocol = parts[2].lower()
                                priority = int(parts[3])
                                ip = parts[4]
                                port = int(parts[5])
                                cand_type = parts[7]

                                # 创建RTCIceCandidate对象
                                ice_candidate = RTCIceCandidate(
                                    component=component,
                                    foundation=foundation,
                                    ip=ip,
                                    port=port,
                                    priority=priority,
                                    protocol=protocol,
                                    type=cand_type,
                                    sdpMid=candidate_data.get("sdpMid"),
                                    sdpMLineIndex=candidate_data.get("sdpMLineIndex"),
                                )

                                await pc.addIceCandidate(ice_candidate)
                                logger.info("已添加ICE候选")
                            else:
                                logger.warning(f"无效的candidate格式: {candidate_str}")
                        except Exception as e:
                            import traceback

                            traceback.print_exc()
                            logger.error(f"处理ICE候选时出错: {e}")

                    elif data["type"] == "ping":
                        # 心跳检测
                        await ws.send_json({"type": "pong"})

                elif msg.type == WSMsgType.ERROR:
                    logger.error(f"WebSocket错误: {ws.exception()}")

        except Exception as e:
            logger.error(f"WebSocket处理异常: {e}")
        finally:
            if ws in connections:
                del connections[ws]
            if pc:
                await pc.close()
                self.pcs.discard(pc)
            await ws.close()
            logger.info("WebSocket连接已关闭")

        return ws

    async def index(self, request):
        return web.FileResponse("./index.html")

    async def health(self, request):
        """健康检查端点"""
        return web.json_response({"status": "ok", "connections": len(connections)})

    async def on_shutdown(self, app):
        """关闭时清理资源"""
        logger.info("正在关闭服务器...")
        # 关闭所有PeerConnection
        close_tasks = []
        for pc in self.pcs:
            close_tasks.append(pc.close())
        await asyncio.gather(*close_tasks, return_exceptions=True)
        self.pcs.clear()

        # 关闭所有WebSocket连接
        for ws in list(connections.keys()):
            await ws.close()

    async def test_turn_server(self):
        """测试TURN服务器连通性"""
        try:
            # 简单的TURN服务器测试
            import socket

            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.settimeout(5)

            # 发送空的UDP包测试连通性
            sock.sendto(b"", ("公网IP地址", 3478))
            sock.close()
            logger.info("TURN服务器端口可达")
            return True
        except Exception as e:
            logger.error(f"TURN服务器测试失败: {e}")
            return False


async def main():
    webrtc_server = WebRTCServer()

    # 测试TURN服务器
    await webrtc_server.test_turn_server()

    app = web.Application()
    app.router.add_get("/", webrtc_server.index)
    app.router.add_get("/health", webrtc_server.health)
    app.router.add_get("/ws", webrtc_server.websocket_handler)
    app.on_shutdown.append(webrtc_server.on_shutdown)

    # 静态文件服务
    app.router.add_static("/", path="./", show_index=True)

    runner = web.AppRunner(app)
    await runner.setup()

    site = web.TCPSite(runner, "0.0.0.0", 8080)
    await site.start()

    logger.info("服务器运行在 http://0.0.0.0:8080")
    logger.info("健康检查: http://0.0.0.0:8080/health")

    # 保持运行
    try:
        await asyncio.Future()
    except KeyboardInterrupt:
        logger.info("接收到中断信号,正在关闭...")

if __name__ == "__main__":
    asyncio.run(main())

coturn服务器

docker-compose.yml

使用docker-compose up -d启动服务

yaml 复制代码
services:
  turnserver:
    image: coturn/coturn:4.6
    container_name: coturn-server
    network_mode: "host"

    restart: always
    privileged: true


    entrypoint: ["turnserver", "-c", "/etc/turn/turnserver.conf"]

    volumes:
      - "./turnserver.conf:/etc/turn/turnserver.conf"

turnserver.conf

conf 复制代码
listening-port=3478
listening-ip=内网IP地址
relay-ip=内网IP地址
external-ip=公网IP地址
realm=公网IP地址
min-port=50000
max-port=60000
verbose
fingerprint
lt-cred-mech
user=user:pass
no-cli
no-multicast-peers
no-tlsv1
no-tlsv1_1

常见错误

测试命令:turnutils_uclient -u user -w pass -r 公网IP地址 -p 3478 -y 公网IP地址

0: : ERROR: Cannot complete Allocation

用户名密码错误

0: : channel bind: error 403 (Forbidden IP)

1: : error 508 (Cannot create socket)

listening-ip、relay-ip、external-ip、realm配置不正确

recv: Connection refused

防火墙端口没有开放

相关推荐
qq_4176950510 分钟前
机器学习与人工智能
jvm·数据库·python
漫随流水12 分钟前
旅游推荐系统(view.py)
前端·数据库·python·旅游
yy我不解释1 小时前
关于comfyui的mmaudio音频生成插件时时间不一致问题(一)
python·ai作画·音视频·comfyui
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ2 小时前
Linux 查询某进程文件所在路径 命令
linux·运维·服务器
紫丁香2 小时前
AutoGen详解一
后端·python·flask
FreakStudio2 小时前
不用费劲编译ulab了!纯Mpy矩阵micronumpy库,单片机直接跑
python·嵌入式·边缘计算·电子diy
05大叔3 小时前
网络基础知识 域名,JSON格式,AI基础
运维·服务器·网络
安当加密4 小时前
无需改 PAM!轻量级 RADIUS + ASP身份认证系统 实现 Linux 登录双因子认证
linux·运维·服务器
清水白石0084 小时前
Free-Threaded Python 实战指南:机遇、风险与 PoC 验证方案
java·python·算法
飞Link5 小时前
具身智能核心架构之 Python 行为树 (py_trees) 深度剖析与实战
开发语言·人工智能·python·架构