1+ # server.py
2+ """
3+ Webcam → WebRTC backend
4+
5+ Fixes for 405:
6+ - CORS preflight handled (OPTIONS on /offer).
7+ - Accept both /offer and /offer/ paths.
8+ - Wildcard origins without credentials (spec-compliant).
9+
10+ Run:
11+ uvicorn server:app --host 0.0.0.0 --port 8000
12+ """
13+
14+ import asyncio
15+ import os
16+ import sys
17+ import contextlib
18+ from typing import Optional , List
19+
20+ import cv2
21+ from fastapi import FastAPI , HTTPException , Response
22+ from fastapi .middleware .cors import CORSMiddleware
23+ from pydantic import BaseModel
24+ from aiortc import (
25+ RTCPeerConnection ,
26+ RTCSessionDescription ,
27+ RTCConfiguration ,
28+ RTCIceServer ,
29+ VideoStreamTrack ,
30+ )
31+
32+ # -----------------------------
33+ # Global camera singleton
34+ # -----------------------------
35+
36+
37+ class _SharedCamera :
38+ def __init__ (self ):
39+ self ._refcount = 0
40+ self ._lock = asyncio .Lock ()
41+ self ._cap = None
42+ self ._frame = None
43+ self ._running = False
44+ self ._reader_task : Optional [asyncio .Task ] = None
45+
46+ async def acquire (self ):
47+ async with self ._lock :
48+ self ._refcount += 1
49+ if self ._cap is None :
50+ idx = int (os .getenv ("CAMERA_INDEX" , "0" ))
51+ self ._cap = _open_camera (idx )
52+ self ._running = True
53+ self ._reader_task = asyncio .create_task (self ._read_loop ())
54+
55+ async def release (self ):
56+ async with self ._lock :
57+ self ._refcount -= 1
58+ if self ._refcount <= 0 :
59+ self ._running = False
60+ if self ._reader_task :
61+ self ._reader_task .cancel ()
62+ with contextlib .suppress (Exception ):
63+ await self ._reader_task
64+ if self ._cap is not None :
65+ self ._cap .release ()
66+ self ._cap = None
67+ self ._frame = None
68+ self ._reader_task = None
69+ self ._refcount = 0
70+
71+ async def _read_loop (self ):
72+ loop = asyncio .get_running_loop ()
73+ try :
74+ while self ._running and self ._cap :
75+ ok , frame = await loop .run_in_executor (None , _read_frame , self ._cap )
76+ if ok :
77+ self ._frame = frame
78+ else :
79+ await asyncio .sleep (0.03 )
80+ except asyncio .CancelledError :
81+ pass
82+
83+ def latest (self ):
84+ return self ._frame
85+
86+ _shared_cam = _SharedCamera ()
87+
88+ def _open_camera (idx : int ) -> cv2 .VideoCapture :
89+ """Try platform-appropriate backends before giving up."""
90+ backends : List [int ] = []
91+ if sys .platform .startswith ("win" ):
92+ backends = [cv2 .CAP_DSHOW , cv2 .CAP_ANY ]
93+ elif sys .platform == "darwin" :
94+ backends = [cv2 .CAP_AVFOUNDATION , cv2 .CAP_ANY ]
95+ else :
96+ backends = [cv2 .CAP_V4L2 , cv2 .CAP_ANY ]
97+
98+ last_error : Optional [str ] = None
99+ for backend in backends :
100+ cap = cv2 .VideoCapture (idx , backend ) if backend != cv2 .CAP_ANY else cv2 .VideoCapture (idx )
101+ if cap .isOpened ():
102+ return cap
103+ cap .release ()
104+ last_error = f"backend={ backend } "
105+
106+ msg = f"Cannot open webcam at index { idx } "
107+ if last_error :
108+ msg += f" (last tried { last_error } )"
109+ msg += ". Try CAMERA_INDEX=1 or ensure camera permissions are granted."
110+ raise RuntimeError (msg )
111+
112+ # -----------------------------
113+ # Media track
114+ # -----------------------------
115+ class CameraVideoTrack (VideoStreamTrack ):
116+ kind = "video"
117+
118+ def __init__ (self ):
119+ super ().__init__ ()
120+
121+ async def recv (self ):
122+ pts , time_base = await self .next_timestamp ()
123+
124+ frame = None
125+ tries = 0
126+ while frame is None :
127+ frame = _shared_cam .latest ()
128+ if frame is not None :
129+ break
130+ tries += 1
131+ if tries >= 100 :
132+ await asyncio .sleep (0.008 )
133+ tries = 0
134+ else :
135+ await asyncio .sleep (0.005 )
136+
137+ from av import VideoFrame
138+ import cv2 as _cv2
139+
140+ rgb = _cv2 .cvtColor (frame , _cv2 .COLOR_BGR2RGB )
141+ video_frame = VideoFrame .from_ndarray (rgb , format = "rgb24" )
142+ video_frame .pts = pts
143+ video_frame .time_base = time_base
144+ return video_frame
145+
146+ # -----------------------------
147+ # FastAPI app
148+ # -----------------------------
149+ app = FastAPI (title = "WebRTC Webcam Streamer" , version = "1.0.1" )
150+
151+ # Proper CORS (no credentials with wildcard)
152+ app .add_middleware (
153+ CORSMiddleware ,
154+ allow_origins = ["*" ],
155+ allow_credentials = False ,
156+ allow_methods = ["GET" , "POST" , "OPTIONS" ],
157+ allow_headers = ["*" ],
158+ )
159+
160+ class SDPModel (BaseModel ):
161+ sdp : str
162+ type : str # "offer"
163+
164+ pcs : List [RTCPeerConnection ] = []
165+
166+ @app .get ("/health" )
167+ def health ():
168+ return {"status" : "ok" }
169+
170+ # Explicit OPTIONS handlers to avoid 405 on preflight in some setups
171+ @app .options ("/offer" )
172+ @app .options ("/offer/" )
173+ def options_offer ():
174+ return Response (status_code = 204 )
175+
176+ # Accept both /offer and /offer/
177+ @app .post ("/offer" )
178+ @app .post ("/offer/" )
179+ async def offer (sdp : SDPModel ):
180+ if sdp .type != "offer" :
181+ raise HTTPException (400 , "type must be 'offer'" )
182+
183+ cfg = RTCConfiguration (iceServers = [RTCIceServer (urls = ["stun:stun.l.google.com:19302" ])])
184+ pc = RTCPeerConnection (configuration = cfg )
185+ pcs .append (pc )
186+
187+ ice_ready = asyncio .get_event_loop ().create_future ()
188+
189+ try :
190+ await _shared_cam .acquire ()
191+ except Exception as e :
192+ await pc .close ()
193+ pcs .remove (pc )
194+ raise HTTPException (500 , f"Camera error: { e } " )
195+
196+ local_video = CameraVideoTrack ()
197+ pc .addTrack (local_video )
198+
199+ if pc .iceGatheringState == "complete" :
200+ if not ice_ready .done ():
201+ ice_ready .set_result (True )
202+
203+ @pc .on ("icegatheringstatechange" )
204+ def on_ice_gathering_state_change ():
205+ if pc .iceGatheringState == "complete" and not ice_ready .done ():
206+ ice_ready .set_result (True )
207+
208+ @pc .on ("iceconnectionstatechange" )
209+ async def on_ice_state_change ():
210+ if pc .iceConnectionState in ("failed" , "closed" , "disconnected" ):
211+ await _cleanup_pc (pc )
212+
213+ offer_desc = RTCSessionDescription (sdp = sdp .sdp , type = sdp .type )
214+ await pc .setRemoteDescription (offer_desc )
215+ answer = await pc .createAnswer ()
216+ await pc .setLocalDescription (answer )
217+
218+ with contextlib .suppress (asyncio .TimeoutError ):
219+ await asyncio .wait_for (ice_ready , timeout = 5 )
220+
221+ return {"sdp" : pc .localDescription .sdp , "type" : pc .localDescription .type }
222+
223+ @app .on_event ("shutdown" )
224+ async def on_shutdown ():
225+ await asyncio .gather (* [_cleanup_pc (pc ) for pc in list (pcs )], return_exceptions = True )
226+
227+ async def _cleanup_pc (pc : RTCPeerConnection ):
228+ if pc in pcs :
229+ pcs .remove (pc )
230+ with contextlib .suppress (Exception ):
231+ await pc .close ()
232+ if not pcs :
233+ with contextlib .suppress (Exception ):
234+ await _shared_cam .release ()
235+
236+
237+ def _read_frame (cap : cv2 .VideoCapture ):
238+ """Run in a thread to grab frames without blocking asyncio loop."""
239+ return cap .read ()
0 commit comments