Konubinix' opinionated web of thoughts

Aiortc to Create a Remote Web Camera With an Android Phone

Fleeting

aiortc, android webcam, ip camera

Using the code from https://github.com/jlaine/aiortc/tree/master/examples/server, keeping only what I need.

This setup involves 4 programs:

  • the signaling server, using peerjs, to make two web applications communicate, listening to 192.168.1.46:9999/peerjs
  • a web server, using aiortc to receive some camera flux, listening to 192.168.1.245:9999/offer
  • a web application, that will send its flux to the aiortc application, hereafter called the camera,
  • a web application to control the camera provider, hereafter called the controller,

setup

mkdir -p ~/test/android_webcam
cd ~/test/android_webcam
pdm init --non-interactive

The only protocol that web applications can use is http (aiohttp). On top of that, I want to bootstrap an RTC communication (aiortc).

Is this setup, The web application will communicate from localhost to 192.168.1.245:9999. This Cross-Origin Resource Sharing is prevented by the browser by default. Therefore we will make the server explicitly allow this connection using a middleware (aiohttp-cors).

pdm add aiohttp aiortc aiohttp-cors
Adding packages to default dependencies: aiohttp, aiortc, aiohttp-cors
  0:00:09 🔒 Lock successful.

Changes are written to pyproject.toml.
STATUS: Resolving packages from lockfile...
All packages are synced to date, nothing to do.

  0:00:00 🎉 All complete! 0/0

code that runs on the server, to save the video

You may have to patch aiortc/contrib/media.py to change stream = self.__container.add_stream("libx264", rate=30) into stream = self.__container.add_stream("libx264", rate=300)

Otherwise, the video will stop streaming with errors like

[libx264 @ 0x34b4180] non-strictly-monotonic PTS
[libx264 @ 0x34b4180] non-strictly-monotonic PTS
[mp4 @ 0x3b1bbc0] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 352768 >= 352768

This is a known issue, see https://github.com/aiortc/aiortc/issues/580, https://github.com/aiortc/aiortc/issues/546 and https://github.com/aiortc/aiortc/issues/331

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import argparse
import asyncio
import json
import logging
import os
import subprocess
import uuid

import numpy as np
from aiohttp import web
import aiohttp_cors
import cv2

from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder, MediaRelay
from aiortc.mediastreams import MediaStreamError
from aiohttp import web
pcs = set()
relay = MediaRelay()
ROOT = os.path.dirname(__file__)

logger = logging.getLogger("pc")
logging.basicConfig(level=logging.INFO)

class VideoSave:

    def __init__(self):
        self.width, self.height = 640, 480
        self.fps = 30
        fourcc = cv2.VideoWriter_fourcc(*'VP80')

        self.out = cv2.VideoWriter('output.webm', fourcc, self.fps, (self.width, self.height))

        # self.process = subprocess.Popen([
        #     'ffmpeg',
        #     '-y',  # Overwrite output file if it exists
        #     '-f', 'rawvideo',
        #     '-vcodec', 'rawvideo',
        #     '-pix_fmt', 'bgr24',
        #     '-s', f'{self.width}x{self.height}',  # Frame size
        #     '-r', str(self.fps),  # Frame rate
        #     '-i', '-',  # Input comes from stdin
        #     '-c:v', 'libvpx',  # Use VP8 codec for WebM
        #     '-b:v', '1M',  # Bitrate
        #     '-f', 'webm',
        #     "output2.webm",
        # ], stdin=subprocess.PIPE)

    async def save(self, track):
        print("start saving frames")
        while True:
            try:
                frame = await track.recv()
            except MediaStreamError:
                print("Ended getting the stream")
                return
            img = frame.to_ndarray(format="bgr24")
            img = cv2.resize(img, (self.width, self.height))

            self.out.write(img) # cv2.cvtColor(np.array(frame.to_image()), cv2.COLOR_RGB2BGR))
            #self.process.stdin.write(img.tobytes())

    def start(self, track):
        print("starting save")
        asyncio.ensure_future(self.save(track))

    def stop(self):
        print("Saving the video")
        self.out.release()
        #self.process.stdin.close()
        #self.process.wait()

async def on_shutdown(app):
    coros = [pc.close() for pc in pcs]
    await asyncio.gather(*coros)
    pcs.clear()

async def offer(request):
    print("offer")
    params = await request.json()
    offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])

    pc = RTCPeerConnection()
    pc_id = "PeerConnection(%s)" % uuid.uuid4()
    pcs.add(pc)

    saver = VideoSave()

    def log_info(msg, *args):
        logging.info(pc_id + " " + msg, *args)

    log_info("Created for %s", request.remote)

    @pc.on("connectionstatechange")
    async def on_connectionstatechange():
        log_info("Connection state is %s", pc.connectionState)
        if pc.connectionState == "failed":
            await pc.close()
            pcs.discard(pc)

    @pc.on("track")
    def on_track(track):
        log_info("Track %s received", track.kind)

        # send it back
        # pc.addTrack(relay.subscribe(track))
        saver.start(track)

        @track.on("ended")
        async def on_ended():
            log_info("Track %s ended", track.kind)
            saver.stop()

    # handle offer
    await pc.setRemoteDescription(offer)

    # send answer
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)

    return web.Response(
        content_type="application/json",
        text=json.dumps(
            {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
        ),
    )


def main():
    app = web.Application()
    app.on_shutdown.append(on_shutdown)
    app.router.add_post("/offer", offer)
    cors = aiohttp_cors.setup(app, defaults={
        "*": aiohttp_cors.ResourceOptions(
            allow_credentials=True,
            expose_headers="*",
            allow_headers="*",
        )
    })
    for route in list(app.router.routes()):
        cors.add(route)
        web.run_app(
            app, access_log=None, host="0.0.0.0", port=9999,
        )


if __name__ == "__main__":
    main()

code that runs on the camera phone

<html>
  <head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script defer src="https://konubinix.eu/ipfs/bafkreic33rowgvvugzgzajagkuwidfnit2dyqyn465iygfs67agsukk24i?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <script src="https://konubinix.eu/ipfs/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3"></script>
    <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
    <script>
      document.addEventListener('alpine:init', () => {
          Alpine.data('app', () => ({
              pc: null,
              message: "",
              video: null,
              dc: null,
              peer: null,
              conn: null,
              peerId: null,
              current_call: null,
              wakelock: null,
              width: {min: 320, max: 5000, value: 640},
              currentwidth: null,
              currentFrameRate: null,
              configure: true,
              bodyClass: "",
              async init () {
                  this.video = document.getElementById("video")
                  this.peer = new Peer("camera", {host: "192.168.1.46", port: 9999, path: "/peerjs/"});
                  this.peer.on('open', (id) => {
                      this.message += `connected to peerjs with id ${id}`
                      this.peerId = id
                  });
                  if(! 'mediaDevices' in navigator) {
                      alert( 'Your browser does not support media devices.' );
                  }
                  await this.setupMediaStream()
                  this.peer.on('connection', async (conn) => {
                      this.message += "connected to the controller"
                      this.conn = conn
                      this.message += "calling the controller"
                      this.current_call = this.peer.call("control", this.mediaStream)
                      this.conn.on("open", async () => {
                          conn.send(JSON.stringify({type: "configure", value: this.pc === null ? true : false}))
                      })
                      conn.on('data', async (data) => {
                          this.message += `got ${data}`
                          const order = JSON.parse(data)
                          if(order.action === "start") {
                              await this.start(this.width)
                              conn.send(JSON.stringify({"type": "answer", "message": "started"}))
                          }
                          else if(order.action === "stop") {
                              await this.stop()
                              conn.send(JSON.stringify({"type": "answer", "message": "stopped"}))
                          }
                          else if(order.action === "setupMediaStream")
                          {
                              this.width.value = order.width
                              await this.setupMediaStream()
                              conn.send(JSON.stringify({"type": "answer", "message": "mediaStreamUpdate"}))
                          }
                          else {
                              this.message("Unrecognized")
                          }
                      });
                  });
                  try{
                      navigator.wakeLock.request("screen").then(
                          (w) => {
                              this.wakelock = w;
                              this.message += "wakelock acquired"
                          }
                      ).catch((err) => {
                          this.message += `${err.name}, ${err.message}`;
                      });
                  } catch (err) {
                      this.message += err.toString()
                  };
              },

              createPeerConnection() {
                  this.pc = new RTCPeerConnection();
                  this.pc.addEventListener('track', (evt) => {
                      this.video.srcObject = evt.streams[0];
                  });
              },

              async setupMediaStream() {
                  this.mediaStream = await navigator.mediaDevices.getUserMedia(
                      {
                          video: {
                              width: { ideal: this.width.value },
                              height: { ideal: this.width.value },
                              frameRate: 30,
                              facingMode: {exact: "environment"},
                          },
                          audio: false,
                      }
                  );
                  // window.ms = this.mediaStream
                  this.currentwidth = this.mediaStream.getVideoTracks()[0].getSettings().width
                  this.currentFrameRate = this.mediaStream.getVideoTracks()[0].getSettings().frameRate
                  this.video.srcObject = this.mediaStream
                  if(this.conn !== null){
                      this.current_call.close()
                      this.current_call = this.peer.call("control", this.mediaStream)
                  }
              },

              async start() {
                  this.configure = false
                  this.bodyClass = "text-slate-700 bg-black"
                  this.createPeerConnection()
                  this.mediaStream.getTracks().forEach((track) => {
                      this.pc.addTrack(track, this.mediaStream);
                  });
                  await this.negociate()
              },
              async negociate() {
                  this.message += "\nneg start"
                  var offer = await this.pc.createOffer();
                  await this.pc.setLocalDescription(offer);
                  try{
                      var resp = await fetch('http://192.168.1.245:9999/offer', {
                          body: JSON.stringify({
                              sdp: offer.sdp,
                              type: offer.type,
                          }),
                          headers: {
                              'Content-Type': "application/json"
                          },
                          method: "POST"
                      });
                  } catch (err) {
                      alert(err.toString())
                      alert(JSON.stringify(err));
                      throw err
                  };
                  this.message += "\nneg sent"

                  var answer = await resp.json()
                  await this.pc.setRemoteDescription(answer);
                  this.message += "\nconnected"

              },
              async stop() {
                  // if (this.wakelock != null) {
                  //     this.wakelock.release().then(() => {
                  //         this.wakelock = null;
                  //         // alert("released");
                  //     }).catch((err) => {
                  //         alert(`${err.name}, ${err.message}`);
                  //     });
                  // }
                  // this.current_call.close()
                  this.pc.getTransceivers().forEach(function(transceiver) {
                      if (transceiver.stop) {
                          transceiver.stop();
                      }
                  });
                  this.pc.getSenders().forEach(function(sender) {
                      sender.track.stop();
                  });

                  // close peer connection
                  setTimeout(async () => {
                      this.pc.close();
                      this.pc = null
                      this.message += "\nall closed"
                      this.conn.send(JSON.stringify({"type": "answer", "message": "allclosed"}))
                      this.bodyClass = ""
                      this.configure = true
                      setTimeout(async () => {
                          this.message += "connecting back"
                          await this.setupMediaStream()
                          this.current_call = this.peer.call("control", this.mediaStream)
                      }, 500)
                  }, 500);

              },
          }))
      })
    </script>
  </head>
  <body x-data="app" :class="bodyClass">
    <div>
      <!-- <button @click="start">Start</button> -->
      <!-- <button @click="stop">Stop</button> -->
      <div x-text="peerId"></div>
      <div x-text="message"></div>
      <div x-show="configure">
        <span x-text="width.value"></span>
        <span x-text="currentwidth"></span>
        <span x-text="currentFrameRate"></span>
        <div class="justify-center sm:px-20 p-15 h-screen" x-data="{show: true}">
          <button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" @click="show = !show ; $refs.video.scrollIntoView()" x-text="show ? 'Hide' : 'Show'"></button>
          <video id="video" @dblclick="$event.target.requestFullscreen()" x-show="show" class="object-scale-down max-h-full" x-ref="video" poster="https://d1tobl1u8ox4qn.cloudfront.net/2018/05/3814a83b-a2d4-4dfd-8945-f1cd003eb16f-1920x1080.jpg" autoplay="true" playsinline="true"></video>
        </div>
      </div>
    </div>
  </body>
</html>

code that runs on the controlling interface, possibly another phone

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script defer src="https://konubinix.eu/ipfs/bafkreic33rowgvvugzgzajagkuwidfnit2dyqyn465iygfs67agsukk24i?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <script src="https://konubinix.eu/ipfs/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3"></script>
    <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
    <script>
      document.addEventListener('alpine:init', () => {
          Alpine.data('app', () => ({
              peer: null,
              peerid: null,
              conn: null,
              message: "",
              video: null,
              configure: true,
              width: {min: 320, max: 6000, value: 640},
              async init() {
                  this.video = document.getElementById('videoElement')
                  this.peer = new Peer("control", {host: "192.168.1.46", port: 9999, path: "/peerjs/"})
                  this.peer.on('open', (id) => {
                      this.message += `\nconnected to peerjs with id ${id}`
                      this.peerid = id
                  });
                  this.peer.on('call', (call) => {
                      this.message += "in call"
                      call.answer();
                      call.on('stream', (stream) => {
                          this.message += "getting stream"
                          this.video.srcObject = stream;
                      });
                  });
                  this.connect()
              },
              connect() {
                  this.conn = this.peer.connect("camera")
                  this.conn.on('data', async (data) => {
                      this.message += `\nreceived ${data}`
                      const message = JSON.parse(data)
                      if(message.type === "answer")
                      {
                          if(message.message === "started")
                          {
                              this.configure = false
                          }
                          else if(message.message === "allclosed")
                          {
                              this.configure = true
                          }
                      }
                      else if(message.type === "configure")
                      {
                          this.configure = message.value
                      }
                      else
                      {
                          this.message += `I don't deal with message of type ${message.type}`
                      }
                  })
                  this.conn.on("open", async () => {
                      this.message += "\nconnected to the camera"
                  })
              },

              async start() {
                  this.conn.send(JSON.stringify({action: "start"}))
                  this.message += "\nsent start"
              },
              async stop() {
                  this.conn.send(JSON.stringify({action: "stop"}))
                  this.message += "\nsent stop"
              },
              async setupMediaStream () {
                  this.conn.send(JSON.stringify({action: "setupMediaStream", width: this.width.value}))
              }
          }))
      })
    </script>
  </head>
  <body>

    <div x-data="app">
      <div x-text="peerid"></div>
      <div>
        <button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" @click="connect">Connect</button>
        <button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" @click="start">Start</button>
        <button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" @click="stop">Stop</button>
      </div>

      <div x-text="message"></div>


      <div x-show="configure">
        <span x-text="width.value"></span>
        <input class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
               type="range"
               :min="width.min"
               :max="width.max"
               x-model="width.value"
               @input.debounce.500ms="setupMediaStream"
               >
      </div>

      <div class="justify-center sm:px-20 p-15 h-screen" x-data="{show: true}">
        <button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" @click="show = !show ; $refs.video.scrollIntoView()" x-text="show ? 'Hide' : 'Show'"></button>
        <video x-ref="video" x-show="show" @dblclick="$event.target.requestFullscreen()" class="object-scale-down max-h-full" id="videoElement" poster="https://d1tobl1u8ox4qn.cloudfront.net/2018/05/3814a83b-a2d4-4dfd-8945-f1cd003eb16f-1920x1080.jpg" autoplay></video>
      </div>

    </div>
  </body>
</html>

now what?

In the server project, run

pdm run ./main.py

In the phone running the camera.

python3 -m http.server 9904

Or, if the phone is too old to install termux or whatever install servdroid

Then connect the phone to http://localhost:9904 . It needs to be localhost, because we need to get access to the stream and we cannot use https because the server is accessed over http.

On the controlling device, the only constraint is to use http, because the peerjs signaling server is available over http only.

You can also run the same python command if you’d like.

some more information

I played a bit with that setup.

[2024-11-05 Tue]
stopped working after 3 hours, while trying to discover where my chicken get out of the fence,

the chromium running on my zte blade s6 froze and I had to try to close it and wait for the “wait or kill” suggestion from android,

In the end, it ran for about 10h, plugged on a 138000 mAh power bank before dying.

Notes linking here