前端代码
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
防火墙端口没有开放