video_monitor/scripts/local_webcam.py
2026-09-06 21:05:03 +08:00

275 lines
11 KiB
Python

"""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]()