I'm trying to write simple python server to browser video streamer using aiortc? For simplicity the server and the browser are in one local network.
The python code:
import asyncio
from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
from av import VideoFrame
import numpy as np
import uuid
import cv2
from aiohttp import web
class BouncingBallTrack(VideoStreamTrack):
def __init__(self):
super().__init__()
self.width = 640
self.height = 480
[...]
async def recv(self):
[...]
return new_frame
async def offer(request):
params = await request.json()
offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
pc = RTCPeerConnection()
# Add local media track
pc.addTrack(BouncingBallTrack())
await pc.setRemoteDescription(offer)
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
return {
"sdp": pc.localDescription.sdp,
"type": pc.localDescription.type
}
if __name__ == "__main__":
app = web.Application()
app.router.add_post("/offer", offer)
app.router.add_static('/', path='static', name='static')
web.run_app(app, port=8080)
The JavaScript code:
pc = new RTCPeerConnection({
iceServers: [] // No ICE servers needed for local network
});
// When remote adds a track, show it in our video element
pc.ontrack = event => {
if (event.track.kind === 'video') {
video.srcObject = event.streams[0];
}
};
// Create an offer to send to the server
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send the offer to the server and get the answer
const response = await fetch('/offer', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sdp: pc.localDescription.sdp,
type: pc.localDescription.type
})
});
const answer = await response.json();
await pc.setRemoteDescription(new RTCSessionDescription(answer));
When a client sends the offer, the server failes with ValueError: None is not in list
on line await pc.setLocalDescription(answer)
. Actually sdp in the offer and in the answer are so strange that it cannot work. The browser and the server sdp do not contain any information about stream tracks, neither contains any local network address to connect. What I'm missing?
You were basically missing the video transceiver and ICE candidates, so your SDP had no m=video
line or network info and blew up on setLocalDescription
. I threw together a quick index.html with a <video>
tag, in main.js
did pc.addTransceiver('video', {direction:'recvonly'})
and waited for onicecandidate
to finish before POSTing. On the Python side I paused until iceGatheringState==='complete'
after createAnswer()
. Now the SDP includes both m=video
and host candidates and the bouncing‐ball stream works over LAN.
Server.py:
import asyncio
from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
from av import VideoFrame
import numpy as np
import cv2
from aiohttp import web
class BouncingBallTrack(VideoStreamTrack):
def __init__(self):
super().__init__()
self.width = 640
self.height = 480
self.position = np.array([100, 100], dtype=float)
self.velocity = np.array([2, 1.5], dtype=float)
self.radius = 20
async def recv(self):
pts, time_base = await self.next_timestamp()
# update position
self.position += self.velocity
for i in (0, 1):
limit = self.width if i == 0 else self.height
if self.position[i] - self.radius < 0 or self.position[i] + self.radius > limit:
self.velocity[i] *= -1
# draw frame
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
cv2.circle(frame, tuple(self.position.astype(int)), self.radius, (0, 255, 0), -1)
# wrap as VideoFrame
new_frame = VideoFrame.from_ndarray(frame, format="bgr24")
new_frame.pts = pts
new_frame.time_base = time_base
return new_frame
async def offer(request):
params = await request.json()
offer = RTCSessionDescription(sdp=params['sdp'], type=params['type'])
pc = RTCPeerConnection()
pc.addTrack(BouncingBallTrack())
# negotiate
await pc.setRemoteDescription(offer)
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
# wait ICE
while pc.iceGatheringState != 'complete':
await asyncio.sleep(0.1)
return web.json_response({
'sdp': pc.localDescription.sdp,
'type': pc.localDescription.type
})
if __name__ == '__main__':
app = web.Application()
app.router.add_post('/offer', offer)
app.router.add_get('/', lambda req: web.FileResponse('static/index.html'))
app.router.add_static('/static/', path='static', show_index=False)
web.run_app(app, port=8080)
main.js:
window.addEventListener('load', async () => {
const pc = new RTCPeerConnection({ iceServers: [] });
const video = document.getElementById('video');
pc.addTransceiver('video', { direction: 'recvonly' });
pc.ontrack = event => {
if (event.track.kind === 'video') {
video.srcObject = event.streams[0];
}
};
pc.onicecandidate = async e => {
if (e.candidate === null) {
const offer = pc.localDescription;
try {
const response = await fetch('/offer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(offer)
});
const answer = await response.json();
await pc.setRemoteDescription(answer);
} catch (err) {
console.error('Error sending offer:', err);
}
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
});
Index.html (Simple mock test):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bouncing Ball Stream</title>
<style>
body { font-family: sans-serif; text-align: center; margin-top: 2rem; }
video { border: 1px solid #ccc; margin-top: 1rem; }
</style>
</head>
<body>
<h1>WebRTC Bouncing Ball</h1>
<video id="video" width="640" height="480" controls autoplay playsinline>
Your browser does not support HTML5 video
</video>
<script src="/static/main.js"></script>
</body>
</html>