1212- Wildcard origins without credentials (spec-compliant).
1313
1414Run:
15- uvicorn server:app --host 0.0.0.0 --port 8000
15+ uv run uvicorn server:app --host 0.0.0.0 --port 8000
1616"""
1717
1818import asyncio
1919import os
2020import sys
2121import contextlib
22- from typing import Optional , List
22+ from typing import Optional , List , Tuple
2323
24+ import numpy as np
2425import cv2
2526from fastapi import FastAPI , HTTPException , Response
2627from fastapi .middleware .cors import CORSMiddleware
3233 RTCIceServer ,
3334 VideoStreamTrack ,
3435)
36+ from av import VideoFrame
37+
3538
3639# -----------------------------
3740# Global camera singleton
3841# -----------------------------
3942
4043
4144class _SharedCamera :
42- def __init__ (self ):
45+ def __init__ (self ) -> None :
4346 self ._refcount = 0
4447 self ._lock = asyncio .Lock ()
45- self ._cap = None
46- self ._frame = None
48+ self ._cap : Optional [ cv2 . VideoCapture ] = None
49+ self ._frame : Optional [ np . ndarray ] = None
4750 self ._running = False
4851 self ._reader_task : Optional [asyncio .Task ] = None
4952
50- async def acquire (self ):
53+ async def acquire (self ) -> None :
5154 async with self ._lock :
5255 self ._refcount += 1
5356 if self ._cap is None :
@@ -56,7 +59,7 @@ async def acquire(self):
5659 self ._running = True
5760 self ._reader_task = asyncio .create_task (self ._read_loop ())
5861
59- async def release (self ):
62+ async def release (self ) -> None :
6063 async with self ._lock :
6164 self ._refcount -= 1
6265 if self ._refcount <= 0 :
@@ -72,7 +75,7 @@ async def release(self):
7275 self ._reader_task = None
7376 self ._refcount = 0
7477
75- async def _read_loop (self ):
78+ async def _read_loop (self ) -> None :
7679 loop = asyncio .get_running_loop ()
7780 try :
7881 while self ._running and self ._cap :
@@ -84,11 +87,13 @@ async def _read_loop(self):
8487 except asyncio .CancelledError :
8588 pass
8689
87- def latest (self ):
90+ def latest (self ) -> Optional [ np . ndarray ] :
8891 return self ._frame
8992
93+
9094_shared_cam = _SharedCamera ()
9195
96+
9297def _open_camera (idx : int ) -> cv2 .VideoCapture :
9398 """Try platform-appropriate backends before giving up."""
9499 backends : List [int ] = []
@@ -101,7 +106,11 @@ def _open_camera(idx: int) -> cv2.VideoCapture:
101106
102107 last_error : Optional [str ] = None
103108 for backend in backends :
104- cap = cv2 .VideoCapture (idx , backend ) if backend != cv2 .CAP_ANY else cv2 .VideoCapture (idx )
109+ cap = (
110+ cv2 .VideoCapture (idx , backend )
111+ if backend != cv2 .CAP_ANY
112+ else cv2 .VideoCapture (idx )
113+ )
105114 if cap .isOpened ():
106115 return cap
107116 cap .release ()
@@ -113,16 +122,17 @@ def _open_camera(idx: int) -> cv2.VideoCapture:
113122 msg += ". Try CAMERA_INDEX=1 or ensure camera permissions are granted."
114123 raise RuntimeError (msg )
115124
125+
116126# -----------------------------
117127# Media track
118128# -----------------------------
119129class CameraVideoTrack (VideoStreamTrack ):
120130 kind = "video"
121131
122- def __init__ (self ):
132+ def __init__ (self ) -> None :
123133 super ().__init__ ()
124134
125- async def recv (self ):
135+ async def recv (self ) -> VideoFrame :
126136 pts , time_base = await self .next_timestamp ()
127137
128138 frame = None
@@ -138,15 +148,13 @@ async def recv(self):
138148 else :
139149 await asyncio .sleep (0.005 )
140150
141- from av import VideoFrame
142- import cv2 as _cv2
143-
144- rgb = _cv2 .cvtColor (frame , _cv2 .COLOR_BGR2RGB )
151+ rgb = cv2 .cvtColor (frame , cv2 .COLOR_BGR2RGB ).astype (np .uint8 )
145152 video_frame = VideoFrame .from_ndarray (rgb , format = "rgb24" )
146153 video_frame .pts = pts
147154 video_frame .time_base = time_base
148155 return video_frame
149156
157+
150158# -----------------------------
151159# FastAPI app
152160# -----------------------------
@@ -161,30 +169,37 @@ async def recv(self):
161169 allow_headers = ["*" ],
162170)
163171
172+
164173class SDPModel (BaseModel ):
165174 sdp : str
166175 type : str # "offer"
167176
177+
168178pcs : List [RTCPeerConnection ] = []
169179
180+
170181@app .get ("/health" )
171- def health ():
182+ def health () -> dict [ str , str ] :
172183 return {"status" : "ok" }
173184
185+
174186# Explicit OPTIONS handlers to avoid 405 on preflight in some setups
175187@app .options ("/offer" )
176188@app .options ("/offer/" )
177- def options_offer ():
189+ def options_offer () -> Response :
178190 return Response (status_code = 204 )
179191
192+
180193# Accept both /offer and /offer/
181194@app .post ("/offer" )
182195@app .post ("/offer/" )
183- async def offer (sdp : SDPModel ):
196+ async def offer (sdp : SDPModel ) -> dict [ str , str ] :
184197 if sdp .type != "offer" :
185198 raise HTTPException (400 , "type must be 'offer'" )
186199
187- cfg = RTCConfiguration (iceServers = [RTCIceServer (urls = ["stun:stun.l.google.com:19302" ])])
200+ cfg = RTCConfiguration (
201+ iceServers = [RTCIceServer (urls = ["stun:stun.l.google.com:19302" ])]
202+ )
188203 pc = RTCPeerConnection (configuration = cfg )
189204 pcs .append (pc )
190205
@@ -205,12 +220,12 @@ async def offer(sdp: SDPModel):
205220 ice_ready .set_result (True )
206221
207222 @pc .on ("icegatheringstatechange" )
208- def on_ice_gathering_state_change ():
223+ def on_ice_gathering_state_change () -> None :
209224 if pc .iceGatheringState == "complete" and not ice_ready .done ():
210225 ice_ready .set_result (True )
211226
212227 @pc .on ("iceconnectionstatechange" )
213- async def on_ice_state_change ():
228+ async def on_ice_state_change () -> None :
214229 if pc .iceConnectionState in ("failed" , "closed" , "disconnected" ):
215230 await _cleanup_pc (pc )
216231
@@ -224,11 +239,13 @@ async def on_ice_state_change():
224239
225240 return {"sdp" : pc .localDescription .sdp , "type" : pc .localDescription .type }
226241
242+
227243@app .on_event ("shutdown" )
228- async def on_shutdown ():
244+ async def on_shutdown () -> None :
229245 await asyncio .gather (* [_cleanup_pc (pc ) for pc in list (pcs )], return_exceptions = True )
230246
231- async def _cleanup_pc (pc : RTCPeerConnection ):
247+
248+ async def _cleanup_pc (pc : RTCPeerConnection ) -> None :
232249 if pc in pcs :
233250 pcs .remove (pc )
234251 with contextlib .suppress (Exception ):
@@ -238,6 +255,6 @@ async def _cleanup_pc(pc: RTCPeerConnection):
238255 await _shared_cam .release ()
239256
240257
241- def _read_frame (cap : cv2 .VideoCapture ):
258+ def _read_frame (cap : cv2 .VideoCapture ) -> Tuple [ bool , Optional [ np . ndarray ]] :
242259 """Run in a thread to grab frames without blocking asyncio loop."""
243- return cap .read ()
260+ return cap .read ()
0 commit comments