diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1e9c65d..269cb37 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ 当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。 -主码流用于清晰预览和可选本机录像,子码流用于多画面预览。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。 +主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。 ## 管理 API diff --git a/README.md b/README.md index 3e46621..8a7256c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。 -HLS 会有数秒延迟。此版本不转码,推荐 H.264;H.265 和不同音频编码的浏览器兼容性取决于浏览器及设备。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 +HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 H.265 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 ## Ubuntu x86_64 部署 @@ -28,7 +28,7 @@ HLS 会有数秒延迟。此版本不转码,推荐 H.264;H.265 和不同音 sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh ``` -安装脚本校验并安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。 +安装脚本从系统软件源安装 FFmpeg,并校验安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。 首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 10 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。 @@ -58,6 +58,7 @@ mediamtx ./data/mediamtx.yml ## 第三方软件 - [MediaMTX](https://github.com/bluenviron/mediamtx) v1.21.1,MIT,部署时独立安装。 +- [FFmpeg](https://ffmpeg.org/),通过系统软件源安装为独立进程,用于按需兼容预览。 - [hls.js](https://github.com/video-dev/hls.js) v1.7.3,Apache-2.0,浏览器构建与许可证位于 `web/vendor/`。 - 官方接口参考:[录像](https://mediamtx.org/docs/features/record)、[回放](https://mediamtx.org/docs/features/playback)、[配置](https://mediamtx.org/docs/references/configuration-file)。 diff --git a/VERSION b/VERSION index b1e80bb..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.1.4 diff --git a/app.py b/app.py index ec3c8fb..380d788 100644 --- a/app.py +++ b/app.py @@ -14,9 +14,11 @@ import os from pathlib import Path import re import secrets +import shlex import shutil import socket import sqlite3 +import sys import threading import time import urllib.error @@ -266,6 +268,12 @@ def write_media_config(): 'record': bool(c['enabled'] and c['record'] and quality == 'main' and not STORAGE['paused']), 'recordDeleteAfter': str(policy['retentionDays'] * 24) + 'h', } + paths[stream_name(c, 'compat')] = { + 'source': 'publisher', 'record': False, + 'runOnDemand': shlex.join([sys.executable, str(ROOT / 'preview.py'), str(DATA)]) if c['enabled'] else '', + 'runOnDemandRestart': True, + 'runOnDemandStartTimeout': '45s', 'runOnDemandCloseAfter': '15s', + } config = { 'logLevel': 'warn', 'logDestinations': ['stdout'], 'api': True, 'apiAddress': '127.0.0.1:' + str(API_PORT), @@ -278,7 +286,8 @@ def write_media_config(): 'authMethod': 'internal', 'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'), 'ips': ['127.0.0.1', '::1'], - 'permissions': [{'action': a} for a in ('read', 'api', 'playback')]}], + 'permissions': [{'action': a} for a in ('read', 'api', 'playback')] + + [{'action': 'publish', 'path': '~^cam_[a-f0-9]{16}_compat$'}]}], 'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'), 'recordSegmentDuration': '5m', 'recordPartDuration': '1s'}, 'paths': paths, @@ -585,7 +594,7 @@ class Handler(BaseHTTPRequestHandler): self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500) def proxy(self, port, path): - conn = http.client.HTTPConnection('127.0.0.1', port, timeout=25) + conn = http.client.HTTPConnection('127.0.0.1', port, timeout=55) headers = {'Authorization': MEDIA_AUTH} if self.headers.get('Range'): headers['Range'] = self.headers['Range'] @@ -601,7 +610,7 @@ class Handler(BaseHTTPRequestHandler): # browser must keep that session query on subsequent playlists. target = url.urlsplit(url.urljoin(path, r.getheader('Location', ''))) if (port != HLS_PORT or target.scheme or target.netloc - or not re.fullmatch(r'/cam_[a-f0-9]{16}_(main|sub)/[a-zA-Z0-9_.-]+', target.path)): + or not re.fullmatch(r'/cam_[a-f0-9]{16}_(main|sub|compat)/[a-zA-Z0-9_.-]+', target.path)): raise Problem('视频服务返回了无效的播放跳转', 502) location = '/media/live' + target.path + ('?' + target.query if target.query else '') self.send_response(r.status) @@ -657,7 +666,7 @@ class Handler(BaseHTTPRequestHandler): self.answer({'items': items}) elif path.startswith('/media/live/'): suffix = path[len('/media/live/'):] - if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub)/[a-zA-Z0-9_.-]+', suffix): + if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat)/[a-zA-Z0-9_.-]+', suffix): raise Problem('视频路径不正确') self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else '')) elif path == '/media/playback': diff --git a/deploy/install.sh b/deploy/install.sh index cbeb8d1..2e237d4 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -6,6 +6,10 @@ set -euo pipefail [[ $(id -u) == 0 ]] || { echo 'Run as root'; exit 1; } [[ $(uname -m) == x86_64 ]] || { echo 'This installer targets x86_64'; exit 1; } command -v python3 >/dev/null +if ! command -v ffmpeg >/dev/null; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg +fi release_dir=$(cd -- "$(dirname -- "$0")/.." && pwd) bind_address=${VISION_BIND:-127.0.0.1} python3 - "$bind_address" <<'PY' diff --git a/preview.py b/preview.py new file mode 100644 index 0000000..618b742 --- /dev/null +++ b/preview.py @@ -0,0 +1,64 @@ +"""On-demand H.264/AAC browser preview from a local MediaMTX substream.""" +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +from urllib.parse import quote + + +def main(): + name = os.environ.get('MTX_PATH', '') + if not re.fullmatch(r'cam_[a-f0-9]{16}_compat', name): + raise SystemExit('Invalid preview path') + data = Path(sys.argv[1]).resolve() + config = json.loads((data / 'mediamtx.yml').read_text(encoding='utf8')) + original = name.removesuffix('_compat') + '_sub' + if config['paths'].get(original, {}).get('source', 'publisher') == 'publisher': + raise SystemExit('Camera is disabled') + secret = next(u['pass'] for u in config['authInternalUsers'] if u['user'] == 'vision') + base = 'rtsp://vision:' + quote(secret, safe='') + '@127.0.0.1:18554/' + executable = shutil.which('ffmpeg') + if not executable: + raise SystemExit('Install FFmpeg to enable compatible preview') + # A bounded 768x432 / 10 fps preview keeps six lenses practical on a small + # computer. Original main/sub streams and recording retain their own codecs. + command = [executable, '-hide_banner', '-loglevel', 'error', '-nostdin', + '-threads', '1', '-filter_threads', '1', '-rtsp_transport', 'tcp', + '-timeout', '15000000', '-i', base + original, + '-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn', + '-vf', 'fps=10,scale=768:432:force_original_aspect_ratio=decrease:force_divisible_by=2', + '-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast', + '-tune', 'zerolatency', '-profile:v', 'baseline', '-level:v', '3.1', + '-pix_fmt', 'yuv420p', '-crf', '26', '-maxrate', '1100k', + '-bufsize', '2200k', '-g', '20', '-keyint_min', '20', '-sc_threshold', '0', + '-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '48k', + '-f', 'rtsp', '-rtsp_transport', 'tcp', base + name] + process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, text=True, errors='replace') + + def stop(_signum, _frame): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + try: + for line in process.stderr: + # Never write connection credentials from FFmpeg diagnostics to logs. + clean = re.sub(r'rtsp://[^\s]+', '[local stream]', line.replace(secret, '[redacted]')) + print(name + ': ' + clean.rstrip(), file=sys.stderr, flush=True) + return process.wait() + finally: + stop(None, None) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/web/app.js b/web/app.js index 4119d49..a435517 100644 --- a/web/app.js +++ b/web/app.js @@ -62,12 +62,12 @@ function renderLive() { const src = `/media/live/cam_${c.id}_${quality}/index.m3u8`; const error = text => {const el=panel.querySelector('.video-error');el.textContent=text;el.hidden=false;}; video.onplaying=()=>{panel.querySelector('.video-empty').hidden=true;panel.querySelector('.video-error').hidden=true;}; - video.onerror=()=>error('浏览器无法解码此码流。可尝试切换清晰度,或将摄像头设为 H.264。'); + video.onerror=()=>error('浏览器无法解码此码流,请在清晰度中选择“兼容 · 流畅画面”。'); if (window.Hls?.isSupported()) { - const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2}); + const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingTimeOut:60000,levelLoadingTimeOut:60000,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2}); hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){ const codecError = /codec|bufferAddCodec|manifestIncompatible/i.test(data.details || ''); - error(codecError ? '浏览器不支持当前视频编码,需要服务器提供兼容码流。' : '画面加载失败('+(data.details || data.type)+')。点击刷新状态可重试。'); + error(codecError ? '浏览器不支持当前视频编码,请选择“兼容 · 流畅画面”。' : '画面加载失败('+(data.details || data.type)+')。点击刷新状态可重试。'); console.warn('Video playback failed', c.id, data.type, data.details); hls.destroy(); }}); diff --git a/web/index.html b/web/index.html index 1752afa..233960d 100644 --- a/web/index.html +++ b/web/index.html @@ -24,7 +24,7 @@
摄像头—
已接通码流—
录像占用—
空间档案—
家庭与公司,分别管理
-

现场画面 LIVE VIEW

+

现场画面 LIVE VIEW