marcosremar2 Claude Opus 4.5 commited on
Commit
7847f44
·
1 Parent(s): 5d69ff8

feat: add webrtc_stream module for WebRTC video streaming

Browse files

- Implement WebRTCManager with aiortc
- Support handle_offer, send_frame, close_connection
- Use WebRTC as default streaming method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Files changed (2) hide show
  1. server/main.py +0 -3
  2. server/webrtc_stream.py +193 -0
server/main.py CHANGED
@@ -368,9 +368,6 @@ async def startup_event():
368
 
369
  @app.get("/")
370
  async def root():
371
- # Use WebSocket page if WebRTC is not available
372
- if webrtc_manager is None:
373
- return FileResponse(Path(__file__).parent / "static" / "index_ws.html")
374
  return FileResponse(Path(__file__).parent / "static" / "index_rtc.html")
375
 
376
 
 
368
 
369
  @app.get("/")
370
  async def root():
 
 
 
371
  return FileResponse(Path(__file__).parent / "static" / "index_rtc.html")
372
 
373
 
server/webrtc_stream.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebRTC Stream Manager for real-time video streaming using aiortc.
3
+ """
4
+ import asyncio
5
+ import logging
6
+ from typing import Dict, Optional
7
+ import numpy as np
8
+ import cv2
9
+
10
+ from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
11
+ from aiortc.contrib.media import MediaRelay
12
+ from av import VideoFrame
13
+
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class VideoFrameTrack(VideoStreamTrack):
19
+ """
20
+ Custom video track that streams frames pushed from the server.
21
+ """
22
+ kind = "video"
23
+
24
+ def __init__(self, fps: int = 30):
25
+ super().__init__()
26
+ self.fps = fps
27
+ self.frame_queue: asyncio.Queue = asyncio.Queue(maxsize=60)
28
+ self._timestamp = 0
29
+ self._time_base = 1 / fps
30
+ self._running = True
31
+
32
+ async def recv(self) -> VideoFrame:
33
+ """Receive the next frame to send."""
34
+ try:
35
+ # Wait for a frame with timeout
36
+ frame_data = await asyncio.wait_for(self.frame_queue.get(), timeout=1.0)
37
+
38
+ # Convert numpy array (BGR) to VideoFrame
39
+ if isinstance(frame_data, np.ndarray):
40
+ # Convert BGR to RGB
41
+ frame_rgb = cv2.cvtColor(frame_data, cv2.COLOR_BGR2RGB)
42
+ frame = VideoFrame.from_ndarray(frame_rgb, format="rgb24")
43
+ else:
44
+ frame = frame_data
45
+
46
+ # Set timing
47
+ frame.pts = self._timestamp
48
+ frame.time_base = self._time_base
49
+ self._timestamp += 1
50
+
51
+ return frame
52
+
53
+ except asyncio.TimeoutError:
54
+ # Return a black frame if no data available
55
+ black_frame = np.zeros((256, 256, 3), dtype=np.uint8)
56
+ frame = VideoFrame.from_ndarray(black_frame, format="rgb24")
57
+ frame.pts = self._timestamp
58
+ frame.time_base = self._time_base
59
+ self._timestamp += 1
60
+ return frame
61
+
62
+ async def push_frame(self, frame: np.ndarray):
63
+ """Push a frame to be sent via WebRTC."""
64
+ try:
65
+ # Drop oldest frame if queue is full
66
+ if self.frame_queue.full():
67
+ try:
68
+ self.frame_queue.get_nowait()
69
+ except asyncio.QueueEmpty:
70
+ pass
71
+ await self.frame_queue.put(frame)
72
+ except Exception as e:
73
+ logger.error(f"Error pushing frame: {e}")
74
+
75
+ def stop(self):
76
+ """Stop the track."""
77
+ self._running = False
78
+ super().stop()
79
+
80
+
81
+ class WebRTCConnection:
82
+ """Manages a single WebRTC peer connection."""
83
+
84
+ def __init__(self, session_id: str, fps: int = 30):
85
+ self.session_id = session_id
86
+ self.pc = RTCPeerConnection()
87
+ self.video_track = VideoFrameTrack(fps=fps)
88
+ self.connected = False
89
+ self._connection_event = asyncio.Event()
90
+
91
+ # Add video track to peer connection
92
+ self.pc.addTrack(self.video_track)
93
+
94
+ # Handle connection state changes
95
+ @self.pc.on("connectionstatechange")
96
+ async def on_connectionstatechange():
97
+ state = self.pc.connectionState
98
+ logger.info(f"[WebRTC:{session_id}] Connection state: {state}")
99
+ if state == "connected":
100
+ self.connected = True
101
+ self._connection_event.set()
102
+ elif state in ("failed", "closed", "disconnected"):
103
+ self.connected = False
104
+ self._connection_event.clear()
105
+
106
+ @self.pc.on("iceconnectionstatechange")
107
+ async def on_iceconnectionstatechange():
108
+ logger.info(f"[WebRTC:{session_id}] ICE state: {self.pc.iceConnectionState}")
109
+
110
+ async def handle_offer(self, sdp: str, type: str) -> dict:
111
+ """Handle an SDP offer and return an answer."""
112
+ offer = RTCSessionDescription(sdp=sdp, type=type)
113
+ await self.pc.setRemoteDescription(offer)
114
+
115
+ answer = await self.pc.createAnswer()
116
+ await self.pc.setLocalDescription(answer)
117
+
118
+ return {
119
+ "sdp": self.pc.localDescription.sdp,
120
+ "type": self.pc.localDescription.type
121
+ }
122
+
123
+ async def send_frame(self, frame: np.ndarray):
124
+ """Send a video frame."""
125
+ if self.connected:
126
+ await self.video_track.push_frame(frame)
127
+
128
+ async def wait_for_connection(self, timeout: float = 5.0) -> bool:
129
+ """Wait for the connection to be established."""
130
+ try:
131
+ await asyncio.wait_for(self._connection_event.wait(), timeout=timeout)
132
+ return True
133
+ except asyncio.TimeoutError:
134
+ return False
135
+
136
+ async def close(self):
137
+ """Close the connection."""
138
+ self.video_track.stop()
139
+ await self.pc.close()
140
+ self.connected = False
141
+
142
+
143
+ class WebRTCManager:
144
+ """Manages multiple WebRTC connections."""
145
+
146
+ def __init__(self):
147
+ self.connections: Dict[str, WebRTCConnection] = {}
148
+ self.relay = MediaRelay()
149
+
150
+ async def handle_offer(self, session_id: str, sdp: str, type: str, fps: int = 30) -> dict:
151
+ """Handle a WebRTC offer for a session."""
152
+ # Close existing connection if any
153
+ if session_id in self.connections:
154
+ await self.close_connection(session_id)
155
+
156
+ # Create new connection
157
+ connection = WebRTCConnection(session_id, fps=fps)
158
+ self.connections[session_id] = connection
159
+
160
+ # Handle the offer
161
+ answer = await connection.handle_offer(sdp, type)
162
+ logger.info(f"[WebRTC] Created connection for session {session_id}")
163
+
164
+ return answer
165
+
166
+ def is_connected(self, session_id: str) -> bool:
167
+ """Check if a session is connected."""
168
+ connection = self.connections.get(session_id)
169
+ return connection.connected if connection else False
170
+
171
+ async def wait_for_connection(self, session_id: str, timeout: float = 5.0) -> bool:
172
+ """Wait for a session to be connected."""
173
+ connection = self.connections.get(session_id)
174
+ if not connection:
175
+ return False
176
+ return await connection.wait_for_connection(timeout)
177
+
178
+ async def send_frame(self, session_id: str, frame: np.ndarray):
179
+ """Send a frame to a session."""
180
+ connection = self.connections.get(session_id)
181
+ if connection and connection.connected:
182
+ await connection.send_frame(frame)
183
+
184
+ async def close_connection(self, session_id: str):
185
+ """Close a session's connection."""
186
+ connection = self.connections.pop(session_id, None)
187
+ if connection:
188
+ await connection.close()
189
+ logger.info(f"[WebRTC] Closed connection for session {session_id}")
190
+
191
+
192
+ # Global manager instance
193
+ webrtc_manager = WebRTCManager()