From 138f774d49b5cf21a86bdf5b35525edee636a11f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 16:58:04 +0800 Subject: [PATCH] feat: add hardware accelerated main-stream HD previews --- ARCHITECTURE.md | 2 +- README.md | 6 ++--- VERSION | 2 +- app.py | 19 ++++++++-------- deploy/install.sh | 9 ++++++++ deploy/zhaovision-media.service | 4 +++- preview.py | 40 ++++++++++++++++++++++----------- web/app.js | 4 ++-- web/index.html | 4 ++-- 9 files changed, 58 insertions(+), 32 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 269cb37..ea126d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ 当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。 -主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。 +主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。 ## 管理 API diff --git a/README.md b/README.md index 8a7256c..aabd558 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。 -HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 H.265 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 +HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。“高清 · 主码流兼容”使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 H.265 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 ## Ubuntu x86_64 部署 @@ -28,7 +28,7 @@ HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baselin sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh ``` -安装脚本从系统软件源安装 FFmpeg,并校验安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。 +安装脚本从系统软件源安装 FFmpeg;检测到 Intel renderD128 时安装 Intel 媒体驱动,授予服务用户 render 组权限,并校验安装固定版本 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 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。 @@ -47,7 +47,7 @@ python3 app.py mediamtx ./data/mediamtx.yml ``` -访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`,HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。 +访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。 ## 数据与恢复 diff --git a/VERSION b/VERSION index 845639e..9faa1b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4 +0.1.5 diff --git a/app.py b/app.py index 380d788..944b479 100644 --- a/app.py +++ b/app.py @@ -268,12 +268,13 @@ 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', - } + for quality in ('compat', 'hd'): + paths[stream_name(c, quality)] = { + '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), @@ -287,7 +288,7 @@ def write_media_config(): 'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'), 'ips': ['127.0.0.1', '::1'], 'permissions': [{'action': a} for a in ('read', 'api', 'playback')] - + [{'action': 'publish', 'path': '~^cam_[a-f0-9]{16}_compat$'}]}], + + [{'action': 'publish', 'path': '~^cam_[a-f0-9]{16}_(compat|hd)$'}]}], 'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'), 'recordSegmentDuration': '5m', 'recordPartDuration': '1s'}, 'paths': paths, @@ -610,7 +611,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|compat)/[a-zA-Z0-9_.-]+', target.path)): + or not re.fullmatch(r'/cam_[a-f0-9]{16}_(main|sub|compat|hd)/[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) @@ -666,7 +667,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|compat)/[a-zA-Z0-9_.-]+', suffix): + if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat|hd)/[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 2e237d4..784d8a9 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -10,6 +10,12 @@ if ! command -v ffmpeg >/dev/null; then apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg fi +if [[ -f /sys/class/drm/renderD128/device/vendor ]] && [[ $(cat /sys/class/drm/renderD128/device/vendor) == 0x8086 ]]; then + if ! dpkg-query -W -f='${Status}' intel-media-va-driver-non-free 2>/dev/null | grep -q 'install ok installed'; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends intel-media-va-driver-non-free + fi +fi release_dir=$(cd -- "$(dirname -- "$0")/.." && pwd) bind_address=${VISION_BIND:-127.0.0.1} python3 - "$bind_address" <<'PY' @@ -19,6 +25,9 @@ for value in sys.argv[1].split(','): assert ip.version==4 and not ip.is_unspecified and not ip.is_multicast and (ip.is_private or ip in ipaddress.ip_network('100.64.0.0/10')), 'Use explicit private or Tailscale addresses' PY getent passwd zhaovision >/dev/null || useradd --system --home-dir /var/lib/zhaovision --shell /usr/sbin/nologin zhaovision +if getent group render >/dev/null; then + usermod -a -G render zhaovision +fi install -d -o zhaovision -g zhaovision -m 700 /var/lib/zhaovision /var/lib/zhaovision/recordings install -d -m 755 /opt/zhaovision /usr/local/lib/zhaovision archive=${MEDIA_ARCHIVE:-/tmp/mediamtx_v1.21.1_linux_amd64.tar.gz} diff --git a/deploy/zhaovision-media.service b/deploy/zhaovision-media.service index eb83d2a..7ed45ce 100644 --- a/deploy/zhaovision-media.service +++ b/deploy/zhaovision-media.service @@ -9,6 +9,7 @@ User=zhaovision Group=zhaovision WorkingDirectory=/var/lib/zhaovision Environment=TZ=UTC +EnvironmentFile=/etc/zhaovision.env ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml Restart=always RestartSec=4 @@ -18,7 +19,8 @@ PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/zhaovision -MemoryMax=1200M +MemoryHigh=1500M +MemoryMax=1800M [Install] WantedBy=multi-user.target diff --git a/preview.py b/preview.py index 618b742..22b2093 100644 --- a/preview.py +++ b/preview.py @@ -1,4 +1,4 @@ -"""On-demand H.264/AAC browser preview from a local MediaMTX substream.""" +"""On-demand H.264/AAC previews; main-stream HD uses Intel VAAPI.""" import json import os from pathlib import Path @@ -12,11 +12,13 @@ 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): + match = re.fullmatch(r'(cam_[a-f0-9]{16})_(compat|hd)', name) + if not match: 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' + hd = match[2] == 'hd' + original = match[1] + ('_main' if hd else '_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') @@ -24,17 +26,29 @@ def main(): 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. + # HD reads the actual main stream. Decode, resize and encode on the GPU; + # never silently start six high-resolution software decoders on a small CPU. + acceleration = [] + if hd: + device = os.environ.get('VISION_VAAPI_DEVICE', '/dev/dri/renderD128') + if not os.access(device, os.R_OK | os.W_OK): + raise SystemExit('HD preview requires an accessible VAAPI render device') + acceleration = ['-hwaccel', 'vaapi', '-hwaccel_device', device, + '-hwaccel_output_format', 'vaapi', '-extra_hw_frames', '8'] + video = ['-vf', 'fps=15,scale_vaapi=w=1920:h=1080:format=nv12:force_original_aspect_ratio=decrease:force_divisible_by=2', + '-c:v', 'h264_vaapi', '-profile:v', 'main', '-level:v', '4.1', + '-rc_mode', 'VBR', '-b:v', '3500k', '-maxrate', '5000k', + '-bufsize', '10000k', '-g', '30', '-bf', '0'] + else: + video = ['-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'] 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', + '-threads', '1', '-filter_threads', '1'] + acceleration + [ + '-rtsp_transport', 'tcp', '-timeout', '15000000', '-i', base + original, + '-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [ '-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, diff --git a/web/app.js b/web/app.js index a435517..0c553ac 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('浏览器无法解码此码流,请在清晰度中选择“兼容 · 流畅画面”。'); + video.onerror=()=>error('浏览器无法解码此码流,请在清晰度中选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。'); if (window.Hls?.isSupported()) { 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 233960d..e95eb6d 100644 --- a/web/index.html +++ b/web/index.html @@ -24,7 +24,7 @@
摄像头—
已接通码流—
录像占用—
空间档案—
家庭与公司,分别管理
-

现场画面 LIVE VIEW

+

现场画面 LIVE VIEW

流畅画面最高 768×432;高清画面从主码流转换,最高 1920×1080、15 帧/秒。原始画面需要浏览器支持摄像头编码。