88- Wildcard origins without credentials (spec-compliant).
99
1010Run:
11- uvicorn server:app --host 0.0.0.0 --port 8000
11+ uv run uvicorn server:app --host 0.0.0.0 --port 8000
1212"""
1313
1414import asyncio
1515import os
1616import sys
1717import contextlib
18- from typing import Optional , List
18+ from typing import Optional , List , Tuple
1919
20+ import numpy as np
2021import cv2
2122from fastapi import FastAPI , HTTPException , Response
2223from fastapi .middleware .cors import CORSMiddleware
2829 RTCIceServer ,
2930 VideoStreamTrack ,
3031)
32+ from av import VideoFrame
33+
3134
3235# -----------------------------
3336# Global camera singleton
3437# -----------------------------
3538
3639
3740class _SharedCamera :
38- def __init__ (self ):
41+ def __init__ (self ) -> None :
3942 self ._refcount = 0
4043 self ._lock = asyncio .Lock ()
41- self ._cap = None
42- self ._frame = None
44+ self ._cap : Optional [ cv2 . VideoCapture ] = None
45+ self ._frame : Optional [ np . ndarray ] = None
4346 self ._running = False
4447 self ._reader_task : Optional [asyncio .Task ] = None
4548
46- async def acquire (self ):
49+ async def acquire (self ) -> None :
4750 async with self ._lock :
4851 self ._refcount += 1
4952 if self ._cap is None :
@@ -52,7 +55,7 @@ async def acquire(self):
5255 self ._running = True
5356 self ._reader_task = asyncio .create_task (self ._read_loop ())
5457
55- async def release (self ):
58+ async def release (self ) -> None :
5659 async with self ._lock :
5760 self ._refcount -= 1
5861 if self ._refcount <= 0 :
@@ -68,7 +71,7 @@ async def release(self):
6871 self ._reader_task = None
6972 self ._refcount = 0
7073
71- async def _read_loop (self ):
74+ async def _read_loop (self ) -> None :
7275 loop = asyncio .get_running_loop ()
7376 try :
7477 while self ._running and self ._cap :
@@ -80,11 +83,13 @@ async def _read_loop(self):
8083 except asyncio .CancelledError :
8184 pass
8285
83- def latest (self ):
86+ def latest (self ) -> Optional [ np . ndarray ] :
8487 return self ._frame
8588
89+
8690_shared_cam = _SharedCamera ()
8791
92+
8893def _open_camera (idx : int ) -> cv2 .VideoCapture :
8994 """Try platform-appropriate backends before giving up."""
9095 backends : List [int ] = []
@@ -97,7 +102,11 @@ def _open_camera(idx: int) -> cv2.VideoCapture:
97102
98103 last_error : Optional [str ] = None
99104 for backend in backends :
100- cap = cv2 .VideoCapture (idx , backend ) if backend != cv2 .CAP_ANY else cv2 .VideoCapture (idx )
105+ cap = (
106+ cv2 .VideoCapture (idx , backend )
107+ if backend != cv2 .CAP_ANY
108+ else cv2 .VideoCapture (idx )
109+ )
101110 if cap .isOpened ():
102111 return cap
103112 cap .release ()
@@ -109,16 +118,17 @@ def _open_camera(idx: int) -> cv2.VideoCapture:
109118 msg += ". Try CAMERA_INDEX=1 or ensure camera permissions are granted."
110119 raise RuntimeError (msg )
111120
121+
112122# -----------------------------
113123# Media track
114124# -----------------------------
115125class CameraVideoTrack (VideoStreamTrack ):
116126 kind = "video"
117127
118- def __init__ (self ):
128+ def __init__ (self ) -> None :
119129 super ().__init__ ()
120130
121- async def recv (self ):
131+ async def recv (self ) -> VideoFrame :
122132 pts , time_base = await self .next_timestamp ()
123133
124134 frame = None
@@ -134,15 +144,13 @@ async def recv(self):
134144 else :
135145 await asyncio .sleep (0.005 )
136146
137- from av import VideoFrame
138- import cv2 as _cv2
139-
140- rgb = _cv2 .cvtColor (frame , _cv2 .COLOR_BGR2RGB )
147+ rgb = cv2 .cvtColor (frame , cv2 .COLOR_BGR2RGB ).astype (np .uint8 )
141148 video_frame = VideoFrame .from_ndarray (rgb , format = "rgb24" )
142149 video_frame .pts = pts
143150 video_frame .time_base = time_base
144151 return video_frame
145152
153+
146154# -----------------------------
147155# FastAPI app
148156# -----------------------------
@@ -157,30 +165,37 @@ async def recv(self):
157165 allow_headers = ["*" ],
158166)
159167
168+
160169class SDPModel (BaseModel ):
161170 sdp : str
162171 type : str # "offer"
163172
173+
164174pcs : List [RTCPeerConnection ] = []
165175
176+
166177@app .get ("/health" )
167- def health ():
178+ def health () -> dict [ str , str ] :
168179 return {"status" : "ok" }
169180
181+
170182# Explicit OPTIONS handlers to avoid 405 on preflight in some setups
171183@app .options ("/offer" )
172184@app .options ("/offer/" )
173- def options_offer ():
185+ def options_offer () -> Response :
174186 return Response (status_code = 204 )
175187
188+
176189# Accept both /offer and /offer/
177190@app .post ("/offer" )
178191@app .post ("/offer/" )
179- async def offer (sdp : SDPModel ):
192+ async def offer (sdp : SDPModel ) -> dict [ str , str ] :
180193 if sdp .type != "offer" :
181194 raise HTTPException (400 , "type must be 'offer'" )
182195
183- cfg = RTCConfiguration (iceServers = [RTCIceServer (urls = ["stun:stun.l.google.com:19302" ])])
196+ cfg = RTCConfiguration (
197+ iceServers = [RTCIceServer (urls = ["stun:stun.l.google.com:19302" ])]
198+ )
184199 pc = RTCPeerConnection (configuration = cfg )
185200 pcs .append (pc )
186201
@@ -201,12 +216,12 @@ async def offer(sdp: SDPModel):
201216 ice_ready .set_result (True )
202217
203218 @pc .on ("icegatheringstatechange" )
204- def on_ice_gathering_state_change ():
219+ def on_ice_gathering_state_change () -> None :
205220 if pc .iceGatheringState == "complete" and not ice_ready .done ():
206221 ice_ready .set_result (True )
207222
208223 @pc .on ("iceconnectionstatechange" )
209- async def on_ice_state_change ():
224+ async def on_ice_state_change () -> None :
210225 if pc .iceConnectionState in ("failed" , "closed" , "disconnected" ):
211226 await _cleanup_pc (pc )
212227
@@ -220,11 +235,13 @@ async def on_ice_state_change():
220235
221236 return {"sdp" : pc .localDescription .sdp , "type" : pc .localDescription .type }
222237
238+
223239@app .on_event ("shutdown" )
224- async def on_shutdown ():
240+ async def on_shutdown () -> None :
225241 await asyncio .gather (* [_cleanup_pc (pc ) for pc in list (pcs )], return_exceptions = True )
226242
227- async def _cleanup_pc (pc : RTCPeerConnection ):
243+
244+ async def _cleanup_pc (pc : RTCPeerConnection ) -> None :
228245 if pc in pcs :
229246 pcs .remove (pc )
230247 with contextlib .suppress (Exception ):
@@ -234,6 +251,6 @@ async def _cleanup_pc(pc: RTCPeerConnection):
234251 await _shared_cam .release ()
235252
236253
237- def _read_frame (cap : cv2 .VideoCapture ):
254+ def _read_frame (cap : cv2 .VideoCapture ) -> Tuple [ bool , Optional [ np . ndarray ]] :
238255 """Run in a thread to grab frames without blocking asyncio loop."""
239- return cap .read ()
256+ return cap .read ()
0 commit comments