From 4993a00dc95d588fa3ba354a1f9244b9ccbfe447 Mon Sep 17 00:00:00 2001 From: Semih Kaiser Date: Wed, 19 Aug 2026 03:35:48 +0200 Subject: [PATCH] VirtualCam --- .gitignore | 1 + requirements.txt | 4 + server.py | 205 ++++++++++++++++++++++++++++++++++++++ static/index.html | 231 +++++++++++++++++++++++++++++++++++++++++++ static/manifest.json | 21 ++++ static/sw.js | 45 +++++++++ 6 files changed, 507 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 static/index.html create mode 100644 static/manifest.json create mode 100644 static/sw.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5e96db --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0bd48e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiohttp +aiortc +av +numpy \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..831c8b0 --- /dev/null +++ b/server.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +WebRTC -> v4l2loopback bridge with stable output resolution (server-side reformat). + +Usage: + python3 server.py --cert cert.pem --key key.pem --video-device /dev/video2 --host 0.0.0.0 --port 8443 --width 640 --height 480 + +Notes: + - If you omit --width/--height the server will use the first incoming frame's size. + - Requires the same dependencies as before: aiohttp, aiortc, av, numpy, ffmpeg installed system-wide. +""" +import argparse +import asyncio +import os +import ssl +import subprocess +from aiohttp import web +from aiortc import RTCPeerConnection, RTCSessionDescription + +ROOT = os.path.join(os.path.dirname(__file__), "static") +pcs = set() + +async def index(request): + return web.FileResponse(os.path.join(ROOT, "index.html")) + +async def offer(request): + params = await request.json() + offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) + + pc = RTCPeerConnection() + pcs.add(pc) + print("Created peer connection", pc) + + # ffmpeg process holder per-track + ffmpeg_proc = {"proc": None} + + @pc.on("iceconnectionstatechange") + def on_ice(): + print("ICE:", pc.iceConnectionState) + if pc.iceConnectionState == "failed": + asyncio.ensure_future(pc.close()) + + @pc.on("track") + def on_track(track): + print("Track received:", track.kind) + + if track.kind != "video": + return + + async def run_video(): + proc = None + target_w = request.app.get("force_width") + target_h = request.app.get("force_height") + + try: + # get first frame (blocks until available) + first_frame = await track.recv() + # if client didn't force res, use first frame's dim + if not target_w or not target_h: + target_w = first_frame.width + target_h = first_frame.height + print(f"No forced size given; using first-frame size {target_w}x{target_h}") + else: + print(f"Using forced output size {target_w}x{target_h}") + + # start ffmpeg once using target size + device = request.app["video_device"] + fps = request.app.get("force_fps", 30) + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", "warning", + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-s", f"{target_w}x{target_h}", + "-r", str(fps), + "-i", "-", + "-f", "v4l2", + "-pix_fmt", "yuv420p", + device + ] + print("Starting ffmpeg:", " ".join(cmd)) + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, bufsize=0) + ffmpeg_proc["proc"] = proc + + # reformat first frame to desired layout + try: + f_reformat = first_frame.reformat(width=target_w, height=target_h, format="rgb24") + except Exception: + # fallback: to_ndarray + numpy resize could go here, but reformat is preferred + f_reformat = first_frame + + arr = f_reformat.to_ndarray(format="rgb24") + try: + proc.stdin.write(arr.tobytes()) + proc.stdin.flush() + except BrokenPipeError: + print("BrokenPipeError writing first frame to ffmpeg") + except Exception as e: + print("Error writing first frame to ffmpeg:", e) + + # loop and process subsequent frames: always reformat to target size/pixfmt + while True: + frame = await track.recv() + # reformat to target size/pixfmt; this creates a new VideoFrame + try: + out_frame = frame.reformat(width=target_w, height=target_h, format="rgb24") + except Exception: + # if reformat fails for any reason, skip the frame but continue + print("Warning: frame.reformat failed; skipping frame") + continue + + arr = out_frame.to_ndarray(format="rgb24") + try: + proc.stdin.write(arr.tobytes()) + except BrokenPipeError: + print("BrokenPipeError while writing frame to ffmpeg; terminating loop") + break + except Exception as e: + print("Error while writing frame to ffmpeg:", e) + break + + except asyncio.CancelledError: + pass + except Exception as e: + print("Video worker error:", e) + finally: + print("Video worker ending") + p = ffmpeg_proc.get("proc") + if p: + try: + p.stdin.close() + except Exception: + pass + try: + p.terminate() + except Exception: + pass + try: + p.wait(timeout=1) + except Exception: + pass + ffmpeg_proc["proc"] = None + + # schedule the video worker + asyncio.ensure_future(run_video()) + + @track.on("ended") + async def on_ended(): + print("Track ended") + + await pc.setRemoteDescription(offer) + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + + return web.json_response( + {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type} + ) + +async def on_shutdown(app): + coros = [pc.close() for pc in pcs] + await asyncio.gather(*coros) + pcs.clear() + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8443) + parser.add_argument("--cert", required=True, help="TLS cert (PEM)") + parser.add_argument("--key", required=True, help="TLS key (PEM)") + parser.add_argument("--video-device", default="/dev/video2", help="v4l2loopback device") + parser.add_argument("--width", type=int, dest="width", help="Force output width (optional)") + parser.add_argument("--height", type=int, dest="height", help="Force output height (optional)") + parser.add_argument("--fps", type=int, dest="fps", default=30, help="Output framerate (default 30)") + args = parser.parse_args() + + if not os.path.exists(args.video_device): + print(f"Warning: video device {args.video_device} does not exist yet. Make sure v4l2loopback is loaded.") + + app = web.Application() + app["video_device"] = args.video_device + app["force_width"] = args.width + app["force_height"] = args.height + app["force_fps"] = args.fps + + app.router.add_get("/", index) + app.router.add_post("/offer", offer) + app.router.add_static("/static/", ROOT, show_index=False) + + async def manifest_handler(request): + return web.FileResponse(os.path.join(ROOT, "manifest.json")) + async def sw_handler(request): + return web.FileResponse(os.path.join(ROOT, "sw.js")) + app.router.add_get("/manifest.json", manifest_handler) + app.router.add_get("/sw.js", sw_handler) + + app.on_shutdown.append(on_shutdown) + + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain(args.cert, args.key) + + web.run_app(app, host=args.host, port=args.port, ssl_context=ssl_context) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3d03c8d --- /dev/null +++ b/static/index.html @@ -0,0 +1,231 @@ + + + + + Phone as Webcam (PWA) + + + + + +
+ +
+

Use this device as a webcam

+ + + +
+ + + + +
+ +

Not connected

+
+ + + + \ No newline at end of file diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..f1c8b42 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Phone as Webcam", + "short_name": "WebCamPhone", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "description": "Use your phone as a webcam for Linux via WebRTC -> v4l2loopback", + "icons": [ + { + "src": "/static/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..3649254 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,45 @@ +// Minimal service worker: cache the app shell so site is quickly available and installable. +const CACHE_NAME = 'phone-webcam-v1'; +const FILES_TO_CACHE = [ + '/', + '/manifest.json', + '/static/index.html', + '/static/index.html', // duplicated intentionally harmless + '/static/icon-192.png' +]; + +self.addEventListener('install', (evt) => { + evt.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(FILES_TO_CACHE)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (evt) => { + evt.waitUntil( + caches.keys().then(keys => Promise.all( + keys.map(k => { if (k !== CACHE_NAME) return caches.delete(k); }) + )) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (evt) => { + // network-first for /offer (dynamic), cache-first for app shell assets + const url = new URL(evt.request.url); + if (url.pathname === '/offer') { + // passthrough to network + evt.respondWith(fetch(evt.request)); + return; + } + + evt.respondWith( + caches.match(evt.request).then(cached => cached || fetch(evt.request).then(resp => { + // optionally cache dynamic responses here + return resp; + }).catch(() => { + // fallback could be a blank response for image or offline page + return caches.match('/'); + })) + ); +}); \ No newline at end of file