"""Open the local webcam, register it, and push it into ZLMediaKit via FFmpeg.""" import argparse from datetime import datetime import json import os from pathlib import Path import socket import sqlite3 import subprocess import sys import time import psutil 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 # The repository's historical .runtime tree may be owned by an elevated # services process. Keep this camera-only helper's state in a user-writable # runtime directory so it can start alongside that process. WORK = ROOT / 'monitor_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_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 zlm_rtmp_port(): try: cfg = json.loads((ROOT / 'config.json').read_text(encoding='utf-8')) return int(cfg.get('mediaRtmpPort') or 10935) except Exception: return 10935 def zlm_ready(port): try: with socket.create_connection(('127.0.0.1', port), timeout=1.0): return True except OSError: return False def start_push(width, height, fps, port): """Pipe OpenCV BGR frames into FFmpeg and push H264 to ZLM over RTMP.""" ffmpeg = os.environ.get('MONITOR_FFMPEG') or 'ffmpeg' fps = max(1, min(int(round(fps)) or 15, 60)) cmd = [ffmpeg, '-hide_banner', '-loglevel', 'warning', '-f', 'rawvideo', '-pix_fmt', 'bgr24', '-s', '%dx%d' % (width, height), '-r', str(fps), '-i', '-', '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', str(fps), '-f', 'flv', 'rtmp://127.0.0.1:%d/live/%s' % (port, STREAM)] # stdout/stderr inherit the supervisor log opened by start(). return subprocess.Popen(cmd, stdin=subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW) def stop_push(push): if push is None or push.poll() is not None: return try: # Closing stdin sends EOF so FFmpeg finalizes the FLV stream and exits. push.stdin.close() push.wait(timeout=5) except Exception: pass if push.poll() is None: push.kill() try: push.wait(timeout=5) except subprocess.TimeoutExpired: pass 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, register it, and push it into ZLM.""" os.environ.update(local_environment()) camera = row = None StreamModel = None push = None rtmp_port = zlm_rtmp_port() 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}' # pull_stream_type=32 (passive RTMP) lets the ZLM on_publish hook accept # this stream and write back forward_state for the play button. row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={ 'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头', 'remark': MARKER, 'pull_stream_type': 32, '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, 'pull_stream_username': '', 'pull_stream_password': '', 'snap_filepath': '', 'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam', 'camera_manufacturer': 'Microsoft', 'camera_owner': '', 'camera_model': 'Integrated Camera', 'camera_parent_id': '', 'camera_civilcode': ''}) if not (row.remark or '').startswith(MARKER): raise RuntimeError('Stream ownership changed') StreamModel.objects.filter(pk=row.pk).update( remark=MARKER, pull_stream_type=32, 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; ' f'RTMP push -> 127.0.0.1:{rtmp_port}/live/{STREAM} once ZLM is up)', flush=True) last_retry = 0.0 last_status = 0.0 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') now = time.monotonic() if push is not None and push.poll() is not None: push = None # FFmpeg exited (ZLM down or connection lost); retry below if push is None and now - last_retry >= 2.0: last_retry = now if zlm_ready(rtmp_port): print('ZLM detected; starting RTMP push', flush=True) push = start_push(width, height, fps, rtmp_port) if push is not None and push.poll() is None: try: push.stdin.write(frame.tobytes()) except (BrokenPipeError, OSError): push = None # FFmpeg died mid-frame; retry on the next cooldown if now - last_status >= 1.0: last_status = now 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': bool(push is not None and push.poll() is None), 'zlm_started': zlm_ready(rtmp_port), 'audio': False, 'recording': False, 'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')}) finally: stop_push(push) if camera is not None: camera.release() if row is not None and StreamModel is not None: StreamModel.objects.filter(pk=row.pk, remark__startswith=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) # Use a fresh per-run log so a previous elevated run cannot lock the file. log_path = WORK / ('supervisor-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '.log') with log_path.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(f'Webcam start failed; inspect {log_path}') 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 | RTMP push starts when ZLM is reachable') 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: # The finally block normally stops the FFmpeg push child; force-stop # both when the graceful path hangs so no push process leaks. try: children = proc.children(recursive=True) except psutil.Error: children = [] for child in children: try: child.kill() except psutil.Error: pass try: proc.kill() proc.wait(timeout=5) except psutil.Error: pass PID_FILE.unlink(missing_ok=True) print('Webcam stopped; RTMP push ended, web server and 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]()