video_monitor/scripts/local_webcam.py

275 lines
11 KiB
Python
Raw Normal View History

2026-09-06 21:05:03 +08:00
"""Open the local webcam, register it, and push it into ZLMediaKit via FFmpeg."""
2026-08-30 22:22:11 +08:00
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
2026-09-06 21:05:03 +08:00
# 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'
2026-08-30 22:22:11 +08:00
SCRIPT = Path(__file__).resolve()
PID_FILE = WORK / 'process.json'
STATUS = WORK / 'status.json'
STOP = WORK / 'stop.request'
STREAM = 'laptop_cam'
DEVICE = 'Integrated Camera'
2026-09-06 21:05:03 +08:00
MARKER = 'Managed by scripts/local_webcam.py;'
2026-09-04 18:16:14 +08:00
CAMERA_INDEX = int(os.environ.get('MONITOR_WEBCAM_INDEX', '0'))
2026-08-30 22:22:11 +08:00
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)
2026-09-06 21:05:03 +08:00
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
2026-08-30 22:22:11 +08:00
2026-09-06 21:05:03 +08:00
def zlm_ready(port):
try:
with socket.create_connection(('127.0.0.1', port), timeout=1.0):
return True
except OSError:
return False
2026-08-30 22:22:11 +08:00
2026-09-06 21:05:03 +08:00
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)
2026-08-30 22:22:11 +08:00
2026-09-06 21:05:03 +08:00
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
2026-08-30 22:22:11 +08:00
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():
2026-09-06 21:05:03 +08:00
"""Keep the physical camera open, register it, and push it into ZLM."""
2026-08-30 22:22:11 +08:00
os.environ.update(local_environment())
2026-09-04 18:16:14 +08:00
camera = row = None
StreamModel = None
2026-09-06 21:05:03 +08:00
push = None
rtmp_port = zlm_rtmp_port()
2026-08-30 22:22:11 +08:00
try:
web = web_process()
if not web:
raise RuntimeError('Start the local web server first')
2026-09-04 18:16:14 +08:00
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)
2026-08-30 22:22:11 +08:00
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
2026-09-04 18:16:14 +08:00
django.setup()
2026-08-30 22:22:11 +08:00
from app.models import StreamModel
existing = StreamModel.objects.filter(code=STREAM).first()
2026-09-04 18:16:14 +08:00
if existing and not (existing.remark or '').startswith('Managed by scripts/local_webcam.py;'):
2026-08-30 22:22:11 +08:00
raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite')
2026-09-04 18:16:14 +08:00
camera_url = f'camera://index/{CAMERA_INDEX}'
2026-09-06 21:05:03 +08:00
# pull_stream_type=32 (passive RTMP) lets the ZLM on_publish hook accept
# this stream and write back forward_state for the play button.
2026-08-30 22:22:11 +08:00
row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={
'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头',
2026-09-06 21:05:03 +08:00
'remark': MARKER, 'pull_stream_type': 32, 'pull_stream_transfer_mode': 0,
2026-09-04 18:16:14 +08:00
'pull_stream_url': camera_url, 'pull_stream_ip': '127.0.0.1', 'pull_stream_port': 0,
2026-09-06 21:05:03 +08:00
'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):
2026-08-30 22:22:11 +08:00
raise RuntimeError('Stream ownership changed')
2026-09-04 18:16:14 +08:00
StreamModel.objects.filter(pk=row.pk).update(
2026-09-06 21:05:03 +08:00
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
2026-08-30 22:22:11 +08:00
while not STOP.exists():
2026-09-04 18:16:14 +08:00
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')
2026-09-06 21:05:03 +08:00
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')})
2026-08-30 22:22:11 +08:00
finally:
2026-09-06 21:05:03 +08:00
stop_push(push)
2026-09-04 18:16:14 +08:00
if camera is not None:
camera.release()
if row is not None and StreamModel is not None:
2026-09-06 21:05:03 +08:00
StreamModel.objects.filter(pk=row.pk, remark__startswith=MARKER).update(forward_state=0)
2026-08-30 22:22:11 +08:00
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)
2026-09-06 21:05:03 +08:00
# 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:
2026-08-30 22:22:11 +08:00
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:
2026-09-06 21:05:03 +08:00
raise RuntimeError(f'Webcam start failed; inspect {log_path}')
2026-08-30 22:22:11 +08:00
if STATUS.exists() and json.loads(STATUS.read_text(encoding='utf-8')).get('ready'):
2026-09-06 21:05:03 +08:00
print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | RTMP push starts when ZLM is reachable')
2026-08-30 22:22:11 +08:00
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:
2026-09-06 21:05:03 +08:00
# 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
2026-08-30 22:22:11 +08:00
PID_FILE.unlink(missing_ok=True)
2026-09-06 21:05:03 +08:00
print('Webcam stopped; RTMP push ended, web server and camera record retained')
2026-08-30 22:22:11 +08:00
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]()