video_monitor/scripts/local_webcam.py
2026-08-30 22:23:12 +08:00

339 lines
16 KiB
Python

"""Explicit Windows webcam service: loopback only, video only, no disk recording.
Does not call ServiceManager.start(): SIP, auto-proxy, recording and telemetry
remain disabled. Holds the shared leader lock so an embedded service cannot race it.
"""
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; loopback video only'
PORTS = {'http': 10002, 'rtsp': 10554, 'rtmp': 10935}
PUSH_URL = f'rtmp://127.0.0.1:{PORTS["rtmp"]}/live/{STREAM}'
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():
os.environ.update(local_environment())
from app.services.lifecycle import ServiceLeaderLock
lock = ServiceLeaderLock()
if not lock.acquire():
raise RuntimeError('Another background-service leader exists; refusing to start')
media = camera = hook = None
row = None
try:
web = web_process()
if not web:
raise RuntimeError('Start the local web server first')
for conn in psutil.net_connections('inet'):
# TIME_WAIT sockets left by a terminated ZLM have no owning
# process (pid 0/None on Windows); only a live owner blocks start.
if conn.laddr and conn.laddr.port in PORTS.values() and conn.pid:
raise RuntimeError('Media port occupied; no unrelated process was stopped')
config_data = json.loads((ROOT / 'config.json').read_text(encoding='utf-8'))
for key, section in [('mediaHttpPort', 'http'), ('mediaRtspPort', 'rtsp'), ('mediaRtmpPort', 'rtmp')]:
if int(config_data[key]) != PORTS[section]:
raise RuntimeError('Configured media ports changed; review local webcam settings first')
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() # Services explicitly disabled; normal existing schema migration only.
from app.models import StreamModel
existing = StreamModel.objects.filter(code=STREAM).first()
if existing and (existing.remark != MARKER or existing.pull_stream_type != 32):
raise RuntimeError('laptop_cam belongs to another stream; refusing to overwrite')
from app.utils.Secrets import get_runtime_secret
api_secret = get_runtime_secret('media_secret')
exe = (ROOT / config_data['mediaStartPath']).resolve()
if not exe.is_relative_to(ROOT) or not exe.is_file() or b'listen_ip' not in exe.read_bytes():
raise RuntimeError('Expected project ZLM binary with listen_ip support')
template = (ROOT / config_data['mediaStartConfigPath']).read_text(encoding='utf-8', errors='replace')
(WORK / 'www').mkdir(exist_ok=True)
hook, hook_url = hook_server()
ini = WORK / 'zlm.ini'
with ini.open('w', encoding='utf-8') as handle:
make_config(template, api_secret, hook_url).write(handle, space_around_delimiters=False)
with (WORK / 'zlm.log').open('ab') as log:
media = subprocess.Popen([str(exe), '-c', str(ini), '-l', '3', '-t', '2',
'--log-dir', str(WORK / 'zlm-logs')], cwd=WORK,
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW)
client = requests.Session()
client.trust_env = False
def api(method, **params):
response = client.post(f'http://127.0.0.1:{PORTS["http"]}/index/api/{method}',
data={'secret': api_secret, **params}, timeout=3)
response.raise_for_status()
return response.json()
for _ in range(30):
if media.poll() is not None:
raise RuntimeError('ZLM exited; see local-webcam/zlm.log')
listeners = check_connections([psutil.Process(media.pid)], set(PORTS.values()))
if listeners == set(PORTS.values()):
break
time.sleep(.3)
else:
raise RuntimeError('Loopback media listeners not ready')
if api('getThreadsLoad').get('code') != 0:
raise RuntimeError('Media API health check failed')
with (WORK / 'ffmpeg.log').open('ab') as log:
camera = subprocess.Popen(capture_command(os.environ['MONITOR_FFMPEG']), cwd=WORK,
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW)
for _ in range(40):
if camera.poll() is not None:
raise RuntimeError('Camera capture failed; see local-webcam/ffmpeg.log')
info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM)
if info.get('code') == 0 and info.get('tracks'):
break
time.sleep(.5)
else:
raise RuntimeError('Camera stream readiness timeout')
if any(track.get('codec_type') != 0 for track in info['tracks']):
raise RuntimeError('Unexpected non-video track')
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': PUSH_URL, 'pull_stream_ip': '127.0.0.1',
'pull_stream_port': PORTS['rtmp'], 'forward_state': 1, 'is_audio': 0,
'record_enable': 0, 'state': 0, 'camera_name': DEVICE, 'camera_device_id': 'local-webcam'})
if row.remark != MARKER:
raise RuntimeError('Stream ownership changed')
StreamModel.objects.filter(pk=row.pk).update(forward_state=1, record_enable=0, is_audio=0)
print('Ready: laptop_cam (Integrated Camera, 640x480/15fps, video only)', flush=True)
while not STOP.exists():
if not web.is_running() or media.poll() is not None or camera.poll() is not None:
raise RuntimeError('Web/camera/media process exited; shutting down capture')
managed = [psutil.Process(os.getpid()), psutil.Process(media.pid), psutil.Process(camera.pid)]
listeners = check_connections(managed, {*PORTS.values(), hook.server_port})
info = api('getMediaInfo', schema='rtmp', vhost='__defaultVhost__', app='live', stream=STREAM)
if info.get('code') != 0 or any(t.get('codec_type') != 0 for t in info.get('tracks', [])):
raise RuntimeError('Video stream health check failed')
if info.get('isRecordingMP4') or info.get('isRecordingHLS'):
raise RuntimeError('Recording detected; stopping camera')
write_json(STATUS, {'ready': True, 'stream_id': row.pk, 'stream': STREAM, 'device': DEVICE,
'media_pid': media.pid, 'camera_pid': camera.pid,
'loopback_ports': sorted(listeners), 'tracks': info['tracks'],
'audio': False, 'recording': False, 'sip': False,
'checked_at': datetime.now().isoformat(timespec='seconds')})
time.sleep(2)
finally:
terminate(camera)
terminate(media)
if hook:
hook.shutdown()
hook.server_close()
if row is not None:
StreamModel.objects.filter(pk=row.pk, remark=MARKER).update(forward_state=0)
lock.release()
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 | video only')
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 and local media stopped; web server and saved stream 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]()