Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7058e8af7 | |||
| 138f774d49 | |||
| c8e9901b90 | |||
| a887a76095 | |||
| 7962a7e50a |
+1
-1
@@ -27,7 +27,7 @@
|
||||
|
||||
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
|
||||
|
||||
主码流用于清晰预览和可选本机录像,子码流用于多画面预览。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||
主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||
|
||||
## 管理 API
|
||||
|
||||
|
||||
@@ -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 帧/秒,供小电脑上的多镜头预览。“高清 · 主码流兼容”使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 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://<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 是单管理员模型。
|
||||
|
||||
## 数据与恢复
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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,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',
|
||||
}
|
||||
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),
|
||||
@@ -278,7 +287,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|hd)$'}]}],
|
||||
'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'),
|
||||
'recordSegmentDuration': '5m', 'recordPartDuration': '1s'},
|
||||
'paths': paths,
|
||||
@@ -302,6 +312,30 @@ def media_json(path, playback=False):
|
||||
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
|
||||
|
||||
|
||||
def media_config_watch():
|
||||
# Reconcile API configuration after atomic file replacements. Multiple rapid
|
||||
# writes can be coalesced by a filesystem watcher; the file remains authority.
|
||||
applied = None
|
||||
while True:
|
||||
try:
|
||||
raw = CONFIG.read_bytes()
|
||||
digest = hashlib.sha256(raw).digest()
|
||||
if digest != applied:
|
||||
desired = json.loads(raw)['paths']
|
||||
existing = {p['name'] for p in media_json('/v3/config/paths/list?itemsPerPage=200').get('items', [])}
|
||||
for name, body in desired.items():
|
||||
action, method = ('patch', 'PATCH') if name in existing else ('add', 'POST')
|
||||
req = urllib.request.Request('http://127.0.0.1:' + str(API_PORT) + '/v3/config/paths/' + action + '/' + name,
|
||||
data=json.dumps(body).encode(), method=method,
|
||||
headers={'Authorization': MEDIA_AUTH, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
response.read()
|
||||
applied = digest
|
||||
except Exception:
|
||||
pass # Retry on the next pass, including after a media process restart.
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def status():
|
||||
try:
|
||||
data = media_json('/v3/paths/list?itemsPerPage=200')
|
||||
@@ -561,7 +595,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']
|
||||
@@ -571,7 +605,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
r = conn.getresponse()
|
||||
if r.status >= 400:
|
||||
raise Problem('暂时没有可播放画面,请确认设备网络、账号、码流路径和录像时间', 503)
|
||||
location = None
|
||||
if 300 <= r.status < 400:
|
||||
# MediaMTX starts an HLS session with a relative redirect. The
|
||||
# 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|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)
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
|
||||
if r.getheader(k):
|
||||
self.send_header(k, r.getheader(k))
|
||||
@@ -622,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)/[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':
|
||||
@@ -682,6 +727,7 @@ def initialize():
|
||||
if __name__ == '__main__':
|
||||
initialize()
|
||||
threading.Thread(target=storage_watch, daemon=True).start()
|
||||
threading.Thread(target=media_config_watch, daemon=True).start()
|
||||
bindings = [h.strip() for h in os.environ.get('VISION_BIND', '127.0.0.1').split(',') if h.strip()]
|
||||
port = int(os.environ.get('VISION_PORT', '8790'))
|
||||
servers = []
|
||||
|
||||
@@ -6,6 +6,16 @@ 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
|
||||
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'
|
||||
@@ -15,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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
"""On-demand H.264/AAC previews; main-stream HD uses Intel VAAPI."""
|
||||
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', '')
|
||||
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'))
|
||||
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')
|
||||
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')
|
||||
# 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',
|
||||
# Jasper Lake can expose CQP only when HuC bitrate control is
|
||||
# unavailable; fixed quality does not require a firmware change.
|
||||
'-rc_mode', 'CQP', '-qp', '25', '-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'] + 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,
|
||||
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())
|
||||
+8
-3
@@ -62,10 +62,15 @@ 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});
|
||||
hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){error('画面暂未接通,请检查设备账号、路径、网络或编码。点击刷新状态可重试。');hls.destroy();}});
|
||||
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)+')。点击刷新状态可重试。');
|
||||
console.warn('Video playback failed', c.id, data.type, data.details);
|
||||
hls.destroy();
|
||||
}});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED,()=>video.play().catch(()=>{}));
|
||||
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {video.src=src;video.play().catch(()=>{});}
|
||||
|
||||
+2
-2
@@ -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="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。</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>
|
||||
|
||||
Reference in New Issue
Block a user