Compare commits

...

1 Commits

Author SHA1 Message Date
Codex 138f774d49 feat: add hardware accelerated main-stream HD previews 2026-10-03 16:58:04 +08:00
9 changed files with 58 additions and 32 deletions
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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://<Tailscale-IP>:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。
安装脚本从系统软件源安装 FFmpeg;检测到 Intel renderD128 时安装 Intel 媒体驱动,授予服务用户 render 组权限,并校验安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://<Tailscale-IP>: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 是单管理员模型。
## 数据与恢复
+1 -1
View File
@@ -1 +1 @@
0.1.4
0.1.5
+10 -9
View File
@@ -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':
+9
View File
@@ -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}
+3 -1
View File
@@ -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
+27 -13
View File
@@ -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,
+2 -2
View File
@@ -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();
}});
+2 -2
View File
@@ -24,7 +24,7 @@
<div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="compat">兼容 · 流畅画面</option><option value="sub">原始 · 子码流</option><option value="main">清晰 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><div id="live-grid" class="live-grid"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="compat">流畅 · 兼容画面</option><option value="hd">高清 · 主码流兼容</option><option value="sub">原始 · 子码流</option><option value="main">原始 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">流畅画面最高 768×432;高清画面从主码流转换,最高 1920×1080、15 帧/秒。原始画面需要浏览器支持摄像头编码。</p><div id="live-grid" class="live-grid"></div></section>
<section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
@@ -36,7 +36,7 @@
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用 H.264 兼容画面(最高 768×432、10 帧/秒)。原始码流与本机录像保留设备编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用 H.264 流畅画面(最高 768×432、10 帧/秒),也可选择从主码流转换的 1080p 高清画面。原始码流与本机录像保留设备编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
</main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>