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