javascriptpythonwebrtcaiortc

How to create simple webrtc server to browser stream example in python?


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?


Solution

  • 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>
    

    Output on my local machine:
    enter image description here

    Output on my phone on the same LAN:
    enter image description here