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

防火墙端口没有开放

相关推荐
chushiyunen2 小时前
django第一个项目blog
python·django
树下水月2 小时前
使用python 发送数据到第三方接口,同步等待太慢
开发语言·python
njsgcs2 小时前
pyautocad获得所选圆弧的弧长总和
开发语言·windows·python
阿巴~阿巴~2 小时前
NumPy数值分析:从基础到高效运算
人工智能·python·numpy
xier_ran2 小时前
Python 切片(Slicing)完全指南:从基础到多维矩阵
开发语言·python·矩阵
百***34952 小时前
Python连接SQL SEVER数据库全流程
数据库·python·sql
2501_941111402 小时前
使用Fabric自动化你的部署流程
jvm·数据库·python
艾莉丝努力练剑4 小时前
【Linux基础开发工具 (三)】Vim从入门到精通(下):效率翻倍的编辑技巧与个性化配置攻略
linux·运维·服务器·c++·ubuntu·centos·vim
cheniie4 小时前
python xmlrpc踩坑记录
python·踩坑·xmlrpc