285 lines
13 KiB
Python
285 lines
13 KiB
Python
"""Open the local webcam and register it without starting ZLMediaKit or FFmpeg."""
|
|
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'
|
|
MARKER = 'Managed by scripts/local_webcam.py; camera registration only; no ZLM'
|
|
PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935}
|
|
PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}'
|
|
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 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():
|
|
"""Keep the physical camera open and register an inventory-only stream."""
|
|
os.environ.update(local_environment())
|
|
camera = row = None
|
|
StreamModel = None
|
|
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}'
|
|
row, _ = StreamModel.objects.get_or_create(code=STREAM, defaults={
|
|
'user_id': 0, 'sort': 0, 'app': 'live', 'name': STREAM, 'nickname': '笔记本内置摄像头',
|
|
'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;'):
|
|
raise RuntimeError('Stream ownership changed')
|
|
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)
|
|
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')
|
|
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': False, 'zlm_started': False, 'audio': False, 'recording': False,
|
|
'sip': False, 'checked_at': datetime.now().isoformat(timespec='seconds')})
|
|
time.sleep(0.2)
|
|
finally:
|
|
if camera is not None:
|
|
camera.release()
|
|
if row is not None and StreamModel is not None:
|
|
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'):
|
|
print('Ready: http://127.0.0.1:10001/stream/index | laptop_cam | camera only; ZLM not started')
|
|
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)
|
|
print('Webcam stopped; web server and registered 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]()
|