video_monitor/scripts/local_webcam.py

285 lines
13 KiB
Python
Raw Normal View History

2026-09-04 18:16:14 +08:00
"""Open the local webcam and register it without starting ZLMediaKit or FFmpeg."""
2026-08-30 22:22:11 +08:00
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'
2026-09-04 18:16:14 +08:00
MARKER = 'Managed by scripts/local_webcam.py; camera registration only; no ZLM'
2026-08-30 22:22:11 +08:00
PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935}
PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}'
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)
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():
2026-09-04 18:16:14 +08:00
"""Keep the physical camera open and register an inventory-only stream."""
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-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-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-04 18:16:14 +08:00
'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;'):
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(
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)
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-08-30 22:22:11 +08:00
write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE,
2026-09-04 18:16:14 +08:00
'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)
2026-08-30 22:22:11 +08:00
finally:
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-08-30 22:22:11 +08:00
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'):
2026-09-04 18:16:14 +08:00
print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | camera only; ZLM not started')
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:
raise RuntimeError('Graceful stop timed out; inspect owned processes before retrying')
PID_FILE.unlink(missing_ok=True)
2026-09-04 18:16:14 +08:00
print('Webcam stopped; web server and registered 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]()