Aiortc to Create a Remote Webcam With an Android Phone
Fleetingaiortc, android webcam
Using the code from https://github.com/jlaine/aiortc/tree/master/examples/server, keeping only what I need.
setup
mkdir -p ~/test/android_webcam
cd ~/test/android_webcam
pdm init --non-interactive
I need a web server (aiohttp), a webRTC server (aiortc), a middleware to enable cors (aiohttp-cors) and a library to deal with image transformation (opencv).
pdm add aiohttp aiortc opencv-python aiohttp-cors
Adding packages to default dependencies: aiohttp, aiortc, opencv-python, aiohttp-cors
STATUS: Resolving dependencies
STATUS: Resolving: new pin python>=3.11,<3.12
STATUS: Resolving: new pin aiohttp 3.9.1
STATUS: Resolving: new pin aiohttp-cors 0.7.0
STATUS: Resolving: new pin aiortc 1.7.0
STATUS: Resolving: new pin opencv-python 4.9.0.80
STATUS: Resolving: new pin numpy 1.26.3
STATUS: Resolving: new pin aioice 0.9.0
STATUS: Resolving: new pin av 11.0.0
STATUS: Resolving: new pin multidict 6.0.4
STATUS: Resolving: new pin yarl 1.9.4
STATUS: Resolving: new pin aiosignal 1.3.1
STATUS: Resolving: new pin frozenlist 1.4.1
STATUS: Resolving: new pin attrs 23.2.0
STATUS: Resolving: new pin cffi 1.16.0
STATUS: Resolving: new pin cryptography 42.0.1
STATUS: Resolving: new pin google-crc32c 1.5.0
STATUS: Resolving: new pin pyee 11.1.0
STATUS: Resolving: new pin pylibsrtp 0.10.0
STATUS: Resolving: new pin pyopenssl 24.0.0
STATUS: Resolving: new pin dnspython 2.5.0
STATUS: Resolving: new pin idna 3.6
STATUS: Resolving: new pin ifaddr 0.2.0
STATUS: Resolving: new pin pycparser 2.21
STATUS: Resolving: new pin typing-extensions 4.9.0
STATUS: Fetching hashes for resolved packages...
🔒 Lock successful
Changes are written to pyproject.toml.
STATUS: Resolving packages from lockfile...
Synchronizing working set with resolved packages: 1 to add, 0 to update, 0 to remove
✔ Install aiohttp-cors 0.7.0 successful
🎉 All complete!
code that runs on the server, to save the video
In the server, I need to
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 ssl
import uuid
import numpy as np
import cv2
from aiohttp import web
from av import VideoFrame
import aiohttp_cors
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder, MediaRelay
from aiohttp import web
pcs = set()
relay = MediaRelay()
ROOT = os.path.dirname(__file__)
logger = logging.getLogger("pc")
logging.basicConfig(level=logging.INFO)
# width, height = 640, 480
# fps = 30
# fourcc = cv2.VideoWriter_fourcc('V','P','8','0')
# out = cv2.VideoWriter('output.webm', fourcc, fps, (width, height))
class VideoTransformTrack(MediaStreamTrack):
"""
A video stream track that transforms frames from an another track.
"""
kind = "video"
def __init__(self, track):
super().__init__() # don't forget this!
self.track = track
self.transform = None
async def recv(self):
frame = await self.track.recv()
# out.write(cv2.cvtColor(np.array(frame.to_image()), cv2.COLOR_RGB2BGR))
if self.transform == "cartoon":
img = frame.to_ndarray(format="bgr24")
# prepare color
img_color = cv2.pyrDown(cv2.pyrDown(img))
for _ in range(6):
img_color = cv2.bilateralFilter(img_color, 9, 9, 7)
img_color = cv2.pyrUp(cv2.pyrUp(img_color))
# prepare edges
img_edges = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
img_edges = cv2.adaptiveThreshold(
cv2.medianBlur(img_edges, 7),
255,
cv2.ADAPTIVE_THRESH_MEAN_C,
cv2.THRESH_BINARY,
9,
2,
)
img_edges = cv2.cvtColor(img_edges, cv2.COLOR_GRAY2RGB)
# combine color and edges
img = cv2.bitwise_and(img_color, img_edges)
# rebuild a VideoFrame, preserving timing information
new_frame = VideoFrame.from_ndarray(img, format="bgr24")
new_frame.pts = frame.pts
new_frame.time_base = frame.time_base
return new_frame
elif self.transform == "edges":
# perform edge detection
img = frame.to_ndarray(format="bgr24")
img = cv2.cvtColor(cv2.Canny(img, 100, 200), cv2.COLOR_GRAY2BGR)
# rebuild a VideoFrame, preserving timing information
new_frame = VideoFrame.from_ndarray(img, format="bgr24")
new_frame.pts = frame.pts
new_frame.time_base = frame.time_base
return new_frame
elif self.transform == "rotate":
# rotate image
img = frame.to_ndarray(format="bgr24")
rows, cols, _ = img.shape
M = cv2.getRotationMatrix2D((cols / 2, rows / 2), frame.time * 45, 1)
img = cv2.warpAffine(img, M, (cols, rows))
# rebuild a VideoFrame, preserving timing information
new_frame = VideoFrame.from_ndarray(img, format="bgr24")
new_frame.pts = frame.pts
new_frame.time_base = frame.time_base
return new_frame
else:
return frame
async def on_shutdown(app):
# close peer connections
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)
def log_info(msg, *args):
logging.info(pc_id + " " + msg, *args)
log_info("Created for %s", request.remote)
# prepare local media
recorder = MediaRecorder(
"test.mp4",
options={
'vbsf': 'hevc_mp4toannexb',
'x264opts': 'keyint=24:min-keyint=24:no-scenecut',
}
)
@pc.on("datachannel")
def on_datachannel(channel):
@channel.on("message")
def on_message(message):
if isinstance(message, str) and message.startswith("ping"):
print(f"received {message}")
@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(
# VideoTransformTrack(
# relay.subscribe(track)
# )
# )
recorder.addTrack(relay.subscribe(track))
@track.on("ended")
async def on_ended():
log_info("Track %s ended", track.kind)
# out.release()
await recorder.stop()
# handle offer
await pc.setRemoteDescription(offer)
await recorder.start()
# 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: true,
}
);
// 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.94: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.