Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 138f774d49 | |||
| c8e9901b90 | |||
| a887a76095 | |||
| 7962a7e50a | |||
| 0674f260a4 |
@@ -0,0 +1,58 @@
|
|||||||
|
# 赵府智家系统架构
|
||||||
|
|
||||||
|
## 部署与职责
|
||||||
|
|
||||||
|
系统运行于自己的 Ubuntu 小电脑,提供局域网和 Tailscale 网页入口。第一阶段是视觉模块。管理程序、SQLite 和 MediaMTX 在同一主机运行,使用独立服务用户;网页只访问管理程序,设备凭据与媒体管理接口留在服务端。
|
||||||
|
|
||||||
|
摄像头与录像机继续在其原有网络工作。实时画面可直接从摄像头取流,也可由录像机提供;读取哪个位置的历史录像必须显式标明。录像机原有录像回放需要设备型号对应的检索、回放适配器。
|
||||||
|
|
||||||
|
## 对象关系
|
||||||
|
|
||||||
|
```text
|
||||||
|
空间档案(家庭 / 公司 / 其他)
|
||||||
|
├─ 地区:省、市、区县、街道
|
||||||
|
├─ 地址:小区或园区、楼栋、单元、楼层、门牌
|
||||||
|
├─ 所属:家庭或单位
|
||||||
|
├─ 摄像头对象(稳定 ID、品牌、型号、序列号、安装位置)
|
||||||
|
│ ├─ 镜头通道 1 → 主码流 / 子码流
|
||||||
|
│ └─ 镜头通道 2 → 主码流 / 子码流
|
||||||
|
└─ 录像机对象(地址、接入驱动、账号、空间引用)
|
||||||
|
|
||||||
|
每个镜头通道分别关联:
|
||||||
|
实时来源:直接摄像头 / 录像机
|
||||||
|
录像来源:本机 / 录像机(可独立于实时来源)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接入层
|
||||||
|
|
||||||
|
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
|
||||||
|
|
||||||
|
主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||||
|
|
||||||
|
## 管理 API
|
||||||
|
|
||||||
|
所有数据接口(除会话状态与健康检查)需要管理员 Cookie 会话。写接口接受 JSON,检查同源请求。
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| GET /api/session | 初始化、登录状态 |
|
||||||
|
| POST /api/setup、/api/login、/api/logout | 管理员会话 |
|
||||||
|
| GET /api/state | 对象、镜头实际码流状态、存储状态 |
|
||||||
|
| POST /api/sites | 保存结构化空间档案 |
|
||||||
|
| POST /api/assets | 保存实体摄像头对象 |
|
||||||
|
| POST /api/recorders | 保存录像机与连接凭据 |
|
||||||
|
| POST /api/cameras | 保存镜头通道(早期接口名保留) |
|
||||||
|
| POST /api/onvif | 只读设备身份、视频源与码流配置 |
|
||||||
|
| POST /api/discover | 有界局域网 RTSP 探测 |
|
||||||
|
| POST /api/storage | 本机录像容量与保留策略 |
|
||||||
|
| GET /api/recordings | 本机录像时段 |
|
||||||
|
| GET /media/live/... | 登录鉴权后的 HLS 代理 |
|
||||||
|
| GET /media/playback | 本机录像回放、下载代理 |
|
||||||
|
|
||||||
|
## 当前交付与后续
|
||||||
|
|
||||||
|
本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。
|
||||||
|
|
||||||
|
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测、多用户细分权限目前没有实现,不作为已具备的能力展示。
|
||||||
|
|
||||||
|
配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
|
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
|
||||||
- **录像来源**:明确区分录像机原有录像与小电脑新保存的录像。
|
- **录像来源**:明确区分录像机原有录像与小电脑新保存的录像。
|
||||||
|
|
||||||
## v0.1.0 功能与边界
|
## 当前功能与边界
|
||||||
|
|
||||||
源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、ONVIF 设备信息与独立镜头媒体配置读取、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。
|
源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、ONVIF 设备信息与独立镜头媒体配置读取、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
|
本机录像默认关闭,用户逐通道开启。默认保留 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 部署
|
## Ubuntu x86_64 部署
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ HLS 会有数秒延迟。此版本不转码,推荐 H.264;H.265 和不同音
|
|||||||
sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh
|
sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
安装脚本校验并安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://<Tailscale-IP>:8790/`。视频服务的 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 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
|
首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 10 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ python3 app.py
|
|||||||
mediamtx ./data/mediamtx.yml
|
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,7 +58,10 @@ mediamtx ./data/mediamtx.yml
|
|||||||
## 第三方软件
|
## 第三方软件
|
||||||
|
|
||||||
- [MediaMTX](https://github.com/bluenviron/mediamtx) v1.21.1,MIT,部署时独立安装。
|
- [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/`。
|
- [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)。
|
- 官方接口参考:[录像](https://mediamtx.org/docs/features/record)、[回放](https://mediamtx.org/docs/features/playback)、[配置](https://mediamtx.org/docs/references/configuration-file)。
|
||||||
|
|
||||||
本项目源码以 MIT 许可证发布。v0.1.0 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
|
[系统架构与对象关系](ARCHITECTURE.md)。
|
||||||
|
|
||||||
|
本项目源码以 MIT 许可证发布。v0.1.x 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
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']),
|
'record': bool(c['enabled'] and c['record'] and quality == 'main' and not STORAGE['paused']),
|
||||||
'recordDeleteAfter': str(policy['retentionDays'] * 24) + 'h',
|
'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 = {
|
config = {
|
||||||
'logLevel': 'warn', 'logDestinations': ['stdout'],
|
'logLevel': 'warn', 'logDestinations': ['stdout'],
|
||||||
'api': True, 'apiAddress': '127.0.0.1:' + str(API_PORT),
|
'api': True, 'apiAddress': '127.0.0.1:' + str(API_PORT),
|
||||||
@@ -278,7 +287,8 @@ def write_media_config():
|
|||||||
'authMethod': 'internal',
|
'authMethod': 'internal',
|
||||||
'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'),
|
'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'),
|
||||||
'ips': ['127.0.0.1', '::1'],
|
'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'),
|
'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'),
|
||||||
'recordSegmentDuration': '5m', 'recordPartDuration': '1s'},
|
'recordSegmentDuration': '5m', 'recordPartDuration': '1s'},
|
||||||
'paths': paths,
|
'paths': paths,
|
||||||
@@ -302,6 +312,30 @@ def media_json(path, playback=False):
|
|||||||
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
|
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():
|
def status():
|
||||||
try:
|
try:
|
||||||
data = media_json('/v3/paths/list?itemsPerPage=200')
|
data = media_json('/v3/paths/list?itemsPerPage=200')
|
||||||
@@ -561,7 +595,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
|
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
|
||||||
|
|
||||||
def proxy(self, port, path):
|
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}
|
headers = {'Authorization': MEDIA_AUTH}
|
||||||
if self.headers.get('Range'):
|
if self.headers.get('Range'):
|
||||||
headers['Range'] = self.headers['Range']
|
headers['Range'] = self.headers['Range']
|
||||||
@@ -571,7 +605,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
r = conn.getresponse()
|
r = conn.getresponse()
|
||||||
if r.status >= 400:
|
if r.status >= 400:
|
||||||
raise Problem('暂时没有可播放画面,请确认设备网络、账号、码流路径和录像时间', 503)
|
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)
|
self.send_response(r.status)
|
||||||
|
if location:
|
||||||
|
self.send_header('Location', location)
|
||||||
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
|
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
|
||||||
if r.getheader(k):
|
if r.getheader(k):
|
||||||
self.send_header(k, r.getheader(k))
|
self.send_header(k, r.getheader(k))
|
||||||
@@ -622,7 +667,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.answer({'items': items})
|
self.answer({'items': items})
|
||||||
elif path.startswith('/media/live/'):
|
elif path.startswith('/media/live/'):
|
||||||
suffix = path[len('/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('视频路径不正确')
|
raise Problem('视频路径不正确')
|
||||||
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
|
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
|
||||||
elif path == '/media/playback':
|
elif path == '/media/playback':
|
||||||
@@ -682,9 +727,15 @@ def initialize():
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
initialize()
|
initialize()
|
||||||
threading.Thread(target=storage_watch, daemon=True).start()
|
threading.Thread(target=storage_watch, daemon=True).start()
|
||||||
bind = os.environ.get('VISION_BIND', '127.0.0.1')
|
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'))
|
port = int(os.environ.get('VISION_PORT', '8790'))
|
||||||
|
servers = []
|
||||||
|
for bind in dict.fromkeys(bindings):
|
||||||
server = ThreadingHTTPServer((bind, port), Handler)
|
server = ThreadingHTTPServer((bind, port), Handler)
|
||||||
server.daemon_threads = True
|
server.daemon_threads = True
|
||||||
|
servers.append(server)
|
||||||
print('ZhaoVision listening on ' + bind + ':' + str(port), flush=True)
|
print('ZhaoVision listening on ' + bind + ':' + str(port), flush=True)
|
||||||
server.serve_forever()
|
for server in servers[1:]:
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
servers[0].serve_forever()
|
||||||
|
|||||||
+17
-3
@@ -1,19 +1,33 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Run from a committed, published release checkout on an x86_64 Ubuntu host.
|
# Run from a committed, published release checkout on an x86_64 Ubuntu host.
|
||||||
# VISION_BIND is the host's Tailscale IPv4; defaults to loopback.
|
# VISION_BIND is a comma-separated list of explicit private/Tailscale IPv4s.
|
||||||
# MEDIA_ARCHIVE optionally points to an already downloaded pinned release archive.
|
# MEDIA_ARCHIVE optionally points to an already downloaded pinned release archive.
|
||||||
[[ $(id -u) == 0 ]] || { echo 'Run as root'; exit 1; }
|
[[ $(id -u) == 0 ]] || { echo 'Run as root'; exit 1; }
|
||||||
[[ $(uname -m) == x86_64 ]] || { echo 'This installer targets x86_64'; exit 1; }
|
[[ $(uname -m) == x86_64 ]] || { echo 'This installer targets x86_64'; exit 1; }
|
||||||
command -v python3 >/dev/null
|
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)
|
release_dir=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
bind_address=${VISION_BIND:-127.0.0.1}
|
bind_address=${VISION_BIND:-127.0.0.1}
|
||||||
python3 - "$bind_address" <<'PY'
|
python3 - "$bind_address" <<'PY'
|
||||||
import ipaddress,sys
|
import ipaddress,sys
|
||||||
ip=ipaddress.ip_address(sys.argv[1])
|
for value in sys.argv[1].split(','):
|
||||||
assert ip.version==4 and (ip.is_loopback or ip in ipaddress.ip_network('100.64.0.0/10')), 'Use a Tailscale or loopback address'
|
ip=ipaddress.ip_address(value.strip())
|
||||||
|
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
|
PY
|
||||||
getent passwd zhaovision >/dev/null || useradd --system --home-dir /var/lib/zhaovision --shell /usr/sbin/nologin zhaovision
|
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 -o zhaovision -g zhaovision -m 700 /var/lib/zhaovision /var/lib/zhaovision/recordings
|
||||||
install -d -m 755 /opt/zhaovision /usr/local/lib/zhaovision
|
install -d -m 755 /opt/zhaovision /usr/local/lib/zhaovision
|
||||||
archive=${MEDIA_ARCHIVE:-/tmp/mediamtx_v1.21.1_linux_amd64.tar.gz}
|
archive=${MEDIA_ARCHIVE:-/tmp/mediamtx_v1.21.1_linux_amd64.tar.gz}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ User=zhaovision
|
|||||||
Group=zhaovision
|
Group=zhaovision
|
||||||
WorkingDirectory=/var/lib/zhaovision
|
WorkingDirectory=/var/lib/zhaovision
|
||||||
Environment=TZ=UTC
|
Environment=TZ=UTC
|
||||||
|
EnvironmentFile=/etc/zhaovision.env
|
||||||
ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml
|
ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=4
|
RestartSec=4
|
||||||
@@ -18,7 +19,8 @@ PrivateTmp=true
|
|||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/var/lib/zhaovision
|
ReadWritePaths=/var/lib/zhaovision
|
||||||
MemoryMax=1200M
|
MemoryHigh=1500M
|
||||||
|
MemoryMax=1800M
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
"""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',
|
||||||
|
'-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'] + 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 src = `/media/live/cam_${c.id}_${quality}/index.m3u8`;
|
||||||
const error = text => {const el=panel.querySelector('.video-error');el.textContent=text;el.hidden=false;};
|
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.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()) {
|
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){error('画面暂未接通,请检查设备账号、路径、网络或编码。点击刷新状态可重试。');hls.destroy();}});
|
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.on(Hls.Events.MANIFEST_PARSED,()=>video.play().catch(()=>{}));
|
||||||
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {video.src=src;video.play().catch(()=>{});}
|
} 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>
|
<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>
|
<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>
|
<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>
|
<section id="page-playback" class="page" hidden>
|
||||||
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
|
<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>
|
<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-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-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-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>
|
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||||
</main>
|
</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>
|
<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