"""Open the local webcam and register it without starting ZLMediaKit or FFmpeg.""" import argparse import configparser from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path import secrets import socket import sqlite3 import subprocess import sys import threading import time import psutil import requests ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from scripts.local_server import PYTHON, local_environment, recorded_process as web_process WORK = ROOT / '.runtime' / 'local-webcam' SCRIPT = Path(__file__).resolve() PID_FILE = WORK / 'process.json' STATUS = WORK / 'status.json' STOP = WORK / 'stop.request' STREAM = 'laptop_cam' DEVICE = 'Integrated Camera' MARKER = 'Managed by scripts/local_webcam.py; camera registration only; no ZLM' PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935} PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}' CAMERA_INDEX = int(os.environ.get('MONITOR_WEBCAM_INDEX', '0')) def write_json(path, data): temp = path.with_suffix('.tmp') temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') os.replace(temp, path) def make_config(source, api_secret, hook_url): config = configparser.ConfigParser(interpolation=None, strict=False) config.optionxform = str config.read_string(source) changes = { 'general': {'listen_ip': '127.0.0.1', 'check_nvidia_dev': '0', 'enable_ffmpeg_log': '0', 'mediaServerId': 'local-webcam', 'wait_add_track_ms': '100', 'maxStreamWaitMS': '3000'}, 'api': {'secret': api_secret, 'apiDebug': '0', 'downloadRoot': str(WORK / 'www'), 'snapRoot': str(WORK / 'www'), 'defaultSnap': ''}, 'cluster': {'origin_url': ''}, 'http': {'port': str(PORTS['http']), 'sslport': '0', 'rootPath': str(WORK / 'www'), 'virtualPath': '', 'dirMenu': '0', 'allow_ip_range': '127.0.0.1', 'allow_cross_domains': '0'}, 'rtmp': {'port': str(PORTS['rtmp']), 'sslport': '0'}, 'rtsp': {'port': str(PORTS['rtsp']), 'sslport': '0', 'rtpTransportType': '0'}, 'rtc': {'port': '0', 'tcpPort': '0'}, 'rtp_proxy': {'port': '0', 'dumpDir': ''}, 'srt': {'port': '0'}, 'shell': {'port': '0'}, 'onvif': {'port': '0'}, 'protocol': {'enable_audio': '0', 'add_mute_audio': '0', 'enable_mp4': '0', 'enable_hls': '0', 'enable_hls_fmp4': '0', 'enable_ts': '0', 'enable_rtsp': '1', 'enable_rtmp': '1', 'enable_fmp4': '1', 'continue_push_ms': '0', 'auto_close': '0', 'mp4_save_path': str(WORK / 'www'), 'hls_save_path': str(WORK / 'www')}, 'record': {'enableFmp4': '0'}, 'hls': {'broadcastRecordTs': '0', 'segKeep': '0'}, # ZLM must never launch another ffmpeg process or save a snapshot here. 'ffmpeg': {'bin': '', 'cmd': '', 'snap': '', 'log': ''}, } for section, values in changes.items(): if not config.has_section(section): config.add_section(section) config[section].update(values) if config.has_section('hook'): config.remove_section('hook') config['hook'] = {'enable': '1', 'on_publish': hook_url, 'timeoutSec': '5', 'retry': '0'} return config def capture_command(ffmpeg): return [ffmpeg, '-hide_banner', '-loglevel', 'warning', '-f', 'dshow', '-rtbufsize', '32M', '-video_size', '640x480', '-framerate', '15', '-i', 'video=' + DEVICE, '-map', '0:v:0', '-an', '-sn', '-dn', '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', '30', '-threads', '2', '-f', 'flv', PUSH_URL] def allowed_publish(data): return (data.get('app') == 'live' and data.get('stream') == STREAM and data.get('schema') == 'rtmp' and data.get('ip') == '127.0.0.1') def hook_server(): route = '/publish/' + secrets.token_urlsafe(32) class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass # Never log the per-run hook token or payload. def do_POST(self): accepted = False try: size = int(self.headers.get('Content-Length', '0')) if self.path == route and self.client_address[0] == '127.0.0.1' and 0 < size <= 16384: self.connection.settimeout(3) accepted = allowed_publish(json.loads(self.rfile.read(size))) except (ValueError, OSError, TypeError): pass data = {'code': 0 if accepted else -1, 'msg': 'video-only local publisher', 'enable_audio': False, 'add_mute_audio': False, 'enable_mp4': False, 'enable_hls': False, 'enable_hls_fmp4': False} body = json.dumps(data).encode() 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) server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() return server, f'http://127.0.0.1:{server.server_port}{route}' def check_connections(processes, allowed_ports): listeners = set() for proc in processes: for conn in proc.connections(kind='inet'): if conn.type == socket.SOCK_DGRAM: raise RuntimeError('Unexpected UDP socket; stopping webcam') if conn.laddr and conn.laddr.ip not in ('127.0.0.1', '::1'): raise RuntimeError(f'Non-loopback binding {conn.laddr} ({conn.status}); stopping webcam') if conn.raddr and conn.raddr.ip not in ('127.0.0.1', '::1'): raise RuntimeError('Non-loopback peer; stopping webcam') if conn.status == psutil.CONN_LISTEN: if conn.laddr.port not in allowed_ports: raise RuntimeError('Unexpected listener; stopping webcam') listeners.add(conn.laddr.port) return listeners def owned_process(): if not PID_FILE.exists(): return None data = json.loads(PID_FILE.read_text(encoding='utf-8')) try: proc = psutil.Process(data['pid']) if abs(proc.create_time() - data['created']) > .01: return None args = proc.cmdline() if (data['root'] != str(ROOT) or len(args) != 3 or Path(args[0]) != PYTHON or Path(args[1]) != SCRIPT or args[2] != 'run'): raise RuntimeError('PID ownership mismatch; no process stopped') return proc except psutil.NoSuchProcess: return None def terminate(child): if child and child.poll() is None: child.terminate() try: child.wait(timeout=8) except subprocess.TimeoutExpired: child.kill() child.wait(timeout=5) def run(): """Keep the physical camera open and register an inventory-only stream.""" os.environ.update(local_environment()) camera = row = None StreamModel = None try: web = web_process() if not web: raise RuntimeError('Start the local web server first') import cv2 camera = cv2.VideoCapture(CAMERA_INDEX, cv2.CAP_DSHOW) if not camera.isOpened(): camera.release() camera = cv2.VideoCapture(CAMERA_INDEX) if not camera.isOpened(): raise RuntimeError(f'Camera open failed: index={CAMERA_INDEX}; check Windows camera permission/device availability') camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640) camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) camera.set(cv2.CAP_PROP_FPS, 15) ok, frame = camera.read() if not ok or frame is None: raise RuntimeError('Camera opened but no video frame was received') width = int(camera.get(cv2.CAP_PROP_FRAME_WIDTH) or 640) height = int(camera.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480) fps = float(camera.get(cv2.CAP_PROP_FPS) or 15) from django.conf import settings database = Path(settings.DATABASES['default']['NAME']) backup_dir = WORK / ('backup-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f')) backup_dir.mkdir() with sqlite3.connect(database.as_uri() + '?mode=ro', uri=True) as source: with sqlite3.connect(backup_dir / database.name) as target: source.backup(target) import django django.setup() from app.models import StreamModel existing = StreamModel.objects.filter(code=STREAM).first() if existing and not (existing.remark or '').startswith('Managed by scripts/local_webcam.py;'): raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite') camera_url = f'camera://index/{CAMERA_INDEX}' row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={ 'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头', 'remark': MARKER, 'pull_stream_type': 0, 'pull_stream_transfer_mode': 0, 'pull_stream_url': camera_url, 'pull_stream_ip': '127.0.0.1', 'pull_stream_port': 0, 'forward_state': 0, 'is_audio': 0, 'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'}) if not (row.remark or '').startswith('Managed by scripts/local_webcam.py;'): raise RuntimeError('Stream ownership changed') StreamModel.objects.filter(pk=row.pk).update( remark=MARKER, pull_stream_type=0, pull_stream_url=camera_url, pull_stream_ip='127.0.0.1', pull_stream_port=0, forward_state=0, record_enable=0, is_audio=0, camera_name=DEVICE, camera_device_id='local-webcam') print(f'Ready: laptop_cam ({DEVICE}, {width}x{height}/{fps:.0f}fps, camera only; ZLM not started)', flush=True) while not STOP.exists(): if not web.is_running(): raise RuntimeError('Local web server exited; stopping camera') ok, frame = camera.read() if not ok or frame is None: raise RuntimeError('Camera frame read failed') write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE, 'camera_index': CAMERA_INDEX, 'width': width, 'height': height, 'fps': fps, 'media_started': False, 'zlm_started': False, 'audio': False, 'recording': False, 'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')}) time.sleep(0.2) finally: if camera is not None: camera.release() if row is not None and StreamModel is not None: StreamModel.objects.filter(pk=row.pk, remark=MARKER).update(forward_state=0) write_json(STATUS, {'ready': False, 'stopped_at': datetime.now().isoformat(timespec='seconds')}) def start(): WORK.mkdir(parents=True, exist_ok=True) proc = owned_process() if proc: print('Webcam process already running:', proc.pid) return STOP.unlink(missing_ok=True) STATUS.unlink(missing_ok=True) with (WORK / 'supervisor.log').open('ab') as log: child = subprocess.Popen([str(PYTHON), str(SCRIPT), 'run'], cwd=ROOT, env=local_environment(), stdin=subprocess.DEVNULL, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) proc = psutil.Process(child.pid) write_json(PID_FILE, {'pid': proc.pid, 'created': proc.create_time(), 'root': str(ROOT)}) for _ in range(60): if child.poll() is not None: raise RuntimeError('Webcam start failed; inspect .runtime/local-webcam/supervisor.log') if STATUS.exists() and json.loads(STATUS.read_text(encoding='utf-8')).get('ready'): print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | camera only; ZLM not started') return time.sleep(1) STOP.touch() raise RuntimeError('Webcam readiness timeout; requested shutdown') def stop(): proc = owned_process() if not proc: print('No owned webcam process running') return STOP.touch() try: proc.wait(timeout=20) except psutil.TimeoutExpired: raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying') PID_FILE.unlink(missing_ok=True) print('Webcam stopped; web server and registered camera record retained') if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['start', 'stop', 'run', 'status']) action = parser.parse_args().action if action == 'status': print(STATUS.read_text(encoding='utf-8') if STATUS.exists() else 'Not started') else: {'start': start, 'stop': stop, 'run': run}[action]()