import argparse import json import os import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse import cv2 CAMERA_LOCK = threading.Lock() def clamp_query(query, name, default, minimum, maximum): try: value = int(query.get(name, [default])[0]) except (TypeError, ValueError): value = default return max(minimum, min(maximum, value)) def open_camera(index, width, height, fps, attempts=10): backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY for _ in range(max(1, attempts)): cap = cv2.VideoCapture(index, backend) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_FPS, fps) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) ok, frame = cap.read() if cap.isOpened() and ok and frame is not None: return cap, frame cap.release() time.sleep(0.5) return None, None class Handler(BaseHTTPRequestHandler): server_version = "HDMIUSBBridge/1.0" def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/health": body = json.dumps({"ok": True, "pid": os.getpid()}).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return if parsed.path != "/stream.mjpg": self.send_error(404) return if not CAMERA_LOCK.acquire(blocking=False): self.send_error(409, "camera is already in use") return query = parse_qs(parsed.query) index = clamp_query(query, "index", 0, 0, 16) width = clamp_query(query, "width", 1920, 160, 3840) height = clamp_query(query, "height", 1080, 120, 2160) fps = clamp_query(query, "fps", 30, 1, 120) quality = clamp_query(query, "quality", 85, 40, 95) cap, frame = open_camera(index, width, height, fps) try: if cap is None: self.send_error(503, f"camera {index} did not return a frame") return self.send_response(200) self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") self.send_header("Cache-Control", "no-store") self.end_headers() interval = 1.0 / fps next_frame_at = time.perf_counter() read_failures = 0 while True: now = time.perf_counter() if now < next_frame_at: time.sleep(next_frame_at - now) elif now - next_frame_at > 3.0 * interval: next_frame_at = now next_frame_at += interval encoded, jpeg = cv2.imencode( ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality], ) if encoded: payload = jpeg.tobytes() self.wfile.write(b"--frame\r\n") self.wfile.write(b"Content-Type: image/jpeg\r\n") self.wfile.write(f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")) self.wfile.write(payload) self.wfile.write(b"\r\n") ok, next_frame = cap.read() if ok and next_frame is not None: frame = next_frame read_failures = 0 else: read_failures += 1 if read_failures >= 60: break except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): pass finally: if cap is not None: cap.release() CAMERA_LOCK.release() def log_message(self, format_text, *args): print(f"[hdmi-bridge] {self.address_string()} {format_text % args}", flush=True) def main(): parser = argparse.ArgumentParser(description="Windows HDMI USB to MJPEG bridge") parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=8091) args = parser.parse_args() print(f"HDMI USB bridge listening on http://{args.host}:{args.port}", flush=True) ThreadingHTTPServer((args.host, args.port), Handler).serve_forever() if __name__ == "__main__": main()