Skip to content

Commit 81accfb

Browse files
Merge pull request #29 from amosproj/feat/webrtc-webcam
Feat/webrtc webcam
2 parents 7849d42 + 4a132d2 commit 81accfb

File tree

4 files changed

+389
-0
lines changed

4 files changed

+389
-0
lines changed

src/backend/webrtc/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# WebRTC Webcam Streamer (Backend)
2+
3+
A minimal **backend service** that captures frames from a local webcam (or any V4L/DirectShow/AVFoundation camera) and **streams them via WebRTC**. Includes a tiny **HTML viewer** and a **Python smoke test client**.

src/backend/webrtc/index.html

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>WebRTC Viewer</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<style>
8+
body { font-family: system-ui, Arial, sans-serif; margin: 24px; }
9+
video { width: 100%; max-width: 960px; background: #000; }
10+
button { padding: 8px 14px; margin: 8px 0; }
11+
.row { margin-top: 8px; }
12+
input[type=text] { width: 420px; }
13+
</style>
14+
</head>
15+
<body>
16+
<h1>WebRTC Webcam Viewer</h1>
17+
18+
<div class="row">
19+
<label for="server">Signaling URL:</label>
20+
<input id="server" type="text" value="">
21+
<button id="connect">Connect</button>
22+
<button id="disconnect" disabled>Disconnect</button>
23+
</div>
24+
25+
<video id="video" autoplay playsinline controls muted></video>
26+
27+
<script>
28+
let pc = null;
29+
30+
function defaultSignalingURL() {
31+
try {
32+
const url = new URL(window.location.href);
33+
if (url.protocol.startsWith("http") && url.port === "8000") {
34+
return `${url.origin}/offer`;
35+
}
36+
} catch (_) {}
37+
return "http://127.0.0.1:8000/offer";
38+
}
39+
40+
function normalizeOfferURL(raw) {
41+
const trimmed = (raw || "").trim();
42+
if (!trimmed) return defaultSignalingURL();
43+
44+
const withScheme = /^[a-zA-Z][\w+\-.]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
45+
try {
46+
const url = new URL(withScheme);
47+
const basePath = url.pathname.replace(/\/+$/, "");
48+
url.pathname = basePath.endsWith("/offer") ? basePath : `${basePath}/offer`;
49+
return url.toString();
50+
} catch (_) {
51+
const base = withScheme.replace(/\/+$/, "");
52+
return base.endsWith("/offer") ? base : `${base}/offer`;
53+
}
54+
}
55+
56+
async function connect() {
57+
const serverInput = document.getElementById("server");
58+
const offerURL = normalizeOfferURL(serverInput.value);
59+
serverInput.value = offerURL;
60+
61+
document.getElementById("connect").disabled = true;
62+
63+
pc = new RTCPeerConnection({
64+
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
65+
});
66+
67+
// Ensure we actually signal that we want to RECEIVE video
68+
pc.addTransceiver("video", { direction: "recvonly" });
69+
70+
// Display incoming video
71+
const videoEl = document.getElementById("video");
72+
pc.ontrack = (evt) => {
73+
const stream = (evt.streams && evt.streams[0]) ? evt.streams[0] : new MediaStream([evt.track]);
74+
if (videoEl.srcObject !== stream) {
75+
videoEl.srcObject = stream;
76+
}
77+
if (videoEl.paused) {
78+
videoEl.play().catch(() => {});
79+
}
80+
};
81+
82+
// Create an SDP offer
83+
const offer = await pc.createOffer();
84+
await pc.setLocalDescription(offer);
85+
86+
// Wait for ICE gathering to complete (non-trickle)
87+
await new Promise((resolve) => {
88+
if (pc.iceGatheringState === "complete") return resolve();
89+
function checkState() {
90+
if (pc.iceGatheringState === "complete") {
91+
pc.removeEventListener("icegatheringstatechange", checkState);
92+
resolve();
93+
}
94+
}
95+
pc.addEventListener("icegatheringstatechange", checkState);
96+
});
97+
98+
// Send SDP to backend
99+
const res = await fetch(offerURL, {
100+
method: "POST",
101+
mode: "cors",
102+
headers: { "Content-Type": "application/json" },
103+
body: JSON.stringify({
104+
sdp: pc.localDescription.sdp,
105+
type: pc.localDescription.type, // "offer"
106+
}),
107+
});
108+
109+
if (!res.ok) {
110+
const t = await res.text().catch(() => "");
111+
throw new Error(`Offer failed (${res.status}): ${t || "unknown error"}`);
112+
}
113+
114+
const answer = await res.json();
115+
// Accept answer from backend
116+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
117+
118+
document.getElementById("disconnect").disabled = false;
119+
}
120+
121+
async function disconnect() {
122+
if (pc) {
123+
try {
124+
pc.getSenders().forEach(s => s.track && s.track.stop());
125+
} catch (_) {}
126+
try { pc.close(); } catch (_) {}
127+
pc = null;
128+
}
129+
document.getElementById("connect").disabled = false;
130+
document.getElementById("disconnect").disabled = true;
131+
}
132+
133+
document.getElementById("connect").onclick = () => connect().catch(err => {
134+
alert(err.message);
135+
document.getElementById("connect").disabled = false;
136+
});
137+
document.getElementById("disconnect").onclick = disconnect;
138+
window.addEventListener("beforeunload", disconnect);
139+
</script>
140+
</body>
141+
</html>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
aiortc
2+
av
3+
fastapi
4+
uvicorn
5+
opencv-python
6+
pydantic

src/backend/webrtc/server.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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

Comments
 (0)