WebRTC PeerConnection含ICE候选仍连接失败问题排查
我正在尝试用运行Python脚本的摄像头设备和React应用建立P2P连接,实现摄像头视频流展示。双方交换的Offer和Answer都包含ICE候选,但ICE连接始终失败。React端收到Answer后,iceconnectionstate先变为checking,几秒后变成failed,错误提示是WebRTC: ICE failed, add a TURN server and see about:webrtc for more details。
之前用Socket.io做信令时能正常运行,现在换成Django Channels(WebSocket)就出问题,而且两端在同一局域网。另外一个奇怪的现象是:如果React端连续发两次Offer,连接能短暂建立几秒后断开。
React端RTCPeerConnection及Offer发送逻辑
const connection = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302", }, ], }); const socket = new WebSocket(signal-url); socket.onopen = () => { const constraints = { offerToReceiveAudio: true, offerToReceiveVideo: true, }; connection.onicegatheringstatechange = (event) => { if (connection.iceGatheringState === "complete") { const offer = { type: connection.localDescription.type, sdp: connection.localDescription.sdp, }; socket.send(JSON.stringify(offer)); } } connection.addEventListener( "track", (evt) => { ... }, false ) connection .createOffer(constraints) .then((offer) => { return connection.setLocalDescription(offer); }) .catch((error) => { console.log(error); }); socket.onmessage = (message:any) => { const msg_json = JSON.parse(message.data); if (msg_json.type === "answer") { connection.setRemoteDescription(msg_json); connection.addTransceiver("video"); } } }
Python摄像头端脚本逻辑
def on_message(ws, message): loop = asyncio.get_event_loop() loop.run_until_complete(self.on_message_async(message)) async def on_message_async(self, message): message_json = json.loads(message) if message_json['type'] == 'offer': connection = RTCPeerConnection( configuration=RTCConfiguration( iceServers=[ RTCIceServer( urls=['stun:stun.l.google.com:19302'] ) ] ) ) rtc_offer = RTCSessionDescription(sdp=message_json["sdp"], type=message_json['type']) await connection.setRemoteDescription(rtc_offer) try: senders = connection.getSenders() kinds = set() for sender in senders: kinds.add(sender.kind) if 'video' in kinds: connection.pc.addTrack(vid_track) except Exception as e: print(e) answer = await connection.createAnswer() await connection.pc.setLocalDescription(answer) try: json_desc = {"type":connection.localDescription.type, "sdp":connection.localDescription.sdp} desc = json.dumps(json_desc) ws.send(desc) except Exception as e: print(e) ws = websocket.WebSocketApp(signal_server_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close)
问题分析及修复方案
1. ICE候选交换时机错误
你现在是等iceGatheringState变为complete才发送Offer,这会错过早期可用的局域网主机候选,导致ICE连接超时。WebRTC的最佳实践是边收集ICE候选边交换,而不是等全部收集完成。
- 修复:监听
onicecandidate事件,每收到一个候选就通过WebSocket发送给对方;同时Offer不用等ICE收集完成,设置完LocalDescription就立即发送。
2. Python端添加Track逻辑完全错误
刚设置完RemoteDescription时,connection.getSenders()里不可能有video类型的sender,导致addTrack代码根本不会执行——Python端没发送视频流,ICE自然会因为没有媒体流而失败。
- 修复:去掉多余的检查逻辑,创建PeerConnection后直接添加视频Track,再处理Offer和Answer。
3. React端添加Transceiver时机太晚
你在收到Answer后才调用addTransceiver("video"),但Offer里已经声明了要接收视频,这时候添加完全没必要,反而可能导致媒体协商不匹配。
- 修复:在创建Offer之前就添加Transceiver,或者依赖Offer的constraints声明即可。
4. Python端PeerConnection属性访问错误
代码里出现connection.pc.addTrack和connection.pc.setLocalDescription,如果connection本身是RTCPeerConnection实例,.pc属于多余的属性访问,会抛出异常(被catch吃掉),导致Answer的LocalDescription没正确设置。
- 修复:直接用
connection.addTrack和await connection.setLocalDescription(answer)。
5. 缺少ICE候选的双向交换
当前代码只交换了Offer/Answer,没有处理对方发送的ICE候选,这是ICE连接成功的必要条件。
- 修复:两端都要监听
icecandidate事件发送候选,同时在收到对方的ICE候选消息时调用addIceCandidate。
修改后的代码示例
React端修改后代码
const connection = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }); const socket = new WebSocket(signal-url); socket.onopen = () => { // 提前添加接收视频/音频的Transceiver connection.addTransceiver("video", { direction: "recvonly" }); connection.addTransceiver("audio", { direction: "recvonly" }); // 实时发送ICE候选 connection.onicecandidate = (event) => { if (event.candidate) { socket.send(JSON.stringify({ type: "ice-candidate", candidate: event.candidate })); } }; connection.addEventListener("track", (evt) => { if (evt.track.kind === "video") { // 把视频流绑定到页面的video元素 document.getElementById("remote-video").srcObject = evt.streams[0]; } }); const constraints = { offerToReceiveAudio: true, offerToReceiveVideo: true, }; // 设置完LocalDescription就发送Offer,不等ICE收集完成 connection.createOffer(constraints) .then((offer) => connection.setLocalDescription(offer)) .then(() => { socket.send(JSON.stringify({ type: connection.localDescription.type, sdp: connection.localDescription.sdp })); }) .catch((error) => console.log(error)); socket.onmessage = (message) => { const msg_json = JSON.parse(message.data); if (msg_json.type === "answer") { connection.setRemoteDescription(msg_json); } else if (msg_json.type === "ice-candidate") { // 处理对方发送的ICE候选 connection.addIceCandidate(new RTCIceCandidate(msg_json.candidate)); } }; };
Python端修改后代码
def on_message(ws, message): loop = asyncio.get_event_loop() # 把ws传入,方便发送ICE候选 loop.run_until_complete(self.on_message_async(ws, message)) async def on_message_async(self, ws, message): message_json = json.loads(message) if message_json['type'] == 'offer': connection = RTCPeerConnection( configuration=RTCConfiguration( iceServers=[RTCIceServer(urls=['stun:stun.l.google.com:19302'])] ) ) # 先添加视频Track,这是核心! try: connection.addTrack(self.vid_track) except Exception as e: print(f"添加视频Track失败: {e}") # 实时发送ICE候选给React端 @connection.on("icecandidate") async def on_icecandidate(candidate): if candidate: ws.send(json.dumps({ "type": "ice-candidate", "candidate": candidate })) # 设置RemoteDescription rtc_offer = RTCSessionDescription(sdp=message_json["sdp"], type=message_json['type']) await connection.setRemoteDescription(rtc_offer) # 创建并发送Answer answer = await connection.createAnswer() await connection.setLocalDescription(answer) # 去掉多余的.pc try: json_desc = { "type": connection.localDescription.type, "sdp": connection.localDescription.sdp } ws.send(json.dumps(json_desc)) except Exception as e: print(f"发送Answer失败: {e}") elif message_json['type'] == 'ice-candidate': # 处理React端发送的ICE候选 await connection.addIceCandidate(RTCIceCandidate(message_json['candidate'])) ws = websocket.WebSocketApp(signal_server_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close)
内容的提问来源于stack exchange,提问作者urico12

