feat: local vision service with structured spaces and camera lens objects

This commit is contained in:
Codex
2026-10-03 16:12:25 +08:00
commit 9d7c20e4db
16 changed files with 1300 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
web/vendor/* -text
+13
View File
@@ -0,0 +1,13 @@
data/
recordings/
runtime/
*.db*
*.log
*.key
*.env
__pycache__/
.venv/
mediamtx
mediamtx.exe
*.tar.gz
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 优创未来
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+64
View File
@@ -0,0 +1,64 @@
# 赵府智家 · 视觉系统
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
- **摄像头对象**:实体设备的稳定 ID、品牌、型号、序列号、镜头数、安装位置和空间引用。
- **镜头通道**:每个物理镜头一个通道,关联摄像头对象,配置主、子码流。双镜头摄像头的主、子码流不能当作两个物理镜头。
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
- **录像来源**:明确区分录像机原有录像与小电脑新保存的录像。
## v0.1.0 功能与边界
源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。
**录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
HLS 会有数秒延迟。此版本不转码,推荐 H.264;H.265 和不同音频编码的浏览器兼容性取决于浏览器及设备。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
## Ubuntu x86_64 部署
先将源文件提交、公开发布,再部署同一 Git 提交的归档。建议放在 `/opt/zhaovision/releases/<commit>/`。Python 3.11 以上,无 pip 运行依赖。
```sh
sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh
```
安装脚本校验并安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://<Tailscale-IP>:8790/`。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。
首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 10 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
```sh
systemctl status zhaovision zhaovision-media
journalctl -u zhaovision -u zhaovision-media --since '10 minutes ago'
```
程序不自动修改网络、防火墙、Tailscale 子网路由或设备密码。录像机仍按其原有设置运行。
## 开发
```sh
python3 app.py
# 在另一个终端运行 MediaMTX,使用首次启动生成的配置
mediamtx ./data/mediamtx.yml
```
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`,HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。
## 数据与恢复
数据库和生成的 MediaMTX 配置含设备连接凭据,存于权限受限的本机目录,不进入 Git。系统管理员可以读取这些凭据;当前版本未提供硬盘加密。备份时停止两个服务并一并备份整个 `/var/lib/zhaovision`。恢复到同一路径、修正服务用户所有权后启动服务。升级时保留数据目录,发布目录按提交留存,回滚 `current` 符号链接并重启服务;后续涉及数据库版本变更时需遵循对应迁移说明。
本仓库不包含真实家庭地址、设备凭据、视频文件或部署主机凭据。
## 第三方软件
- [MediaMTX](https://github.com/bluenviron/mediamtx) v1.21.1,MIT,部署时独立安装。
- [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)。
本项目源码以 MIT 许可证发布。v0.1.0 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
+1
View File
@@ -0,0 +1 @@
0.1.0
+674
View File
@@ -0,0 +1,674 @@
"""Local video management. Python standard library + an external MediaMTX process."""
import base64
import concurrent.futures
import datetime as dt
import hashlib
import hmac
import http.client
import http.cookies
import ipaddress
import json
import math
import mimetypes
import os
from pathlib import Path
import re
import secrets
import shutil
import socket
import sqlite3
import threading
import time
import urllib.error
import urllib.parse as url
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = Path(__file__).resolve().parent
os.umask(0o077)
DATA = Path(os.environ.get('VISION_DATA', ROOT / 'data')).resolve()
DATA.mkdir(mode=0o700, parents=True, exist_ok=True)
RECORDINGS = Path(os.environ.get('VISION_RECORDINGS', DATA / 'recordings')).resolve()
RECORDINGS.mkdir(mode=0o700, parents=True, exist_ok=True)
CONFIG = DATA / 'mediamtx.yml'
LOCK = threading.RLock()
DB = sqlite3.connect(DATA / 'vision.db', check_same_thread=False)
DB.row_factory = sqlite3.Row
DB.executescript('''
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sites (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS cameras (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS assets (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS recorders (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, expires REAL NOT NULL);
CREATE TABLE IF NOT EXISTS audit (id INTEGER PRIMARY KEY, at TEXT, action TEXT, name TEXT);
''')
DB.commit()
os.chmod(DATA / 'vision.db', 0o600)
STARTED = time.time()
STORAGE = {'freeGB': None, 'usedGB': None, 'paused': False, 'reason': ''}
RECENT_RECORDS = {}
SCAN = {'running': False, 'items': [], 'error': '', 'network': '', 'at': None}
ATTEMPTS = {}
API_PORT, HLS_PORT, PLAYBACK_PORT = 19997, 18888, 19996
MEDIA_AUTH = None
class Problem(Exception):
def __init__(self, message, status=400):
super().__init__(message)
self.status = status
def setting(key, default=None):
with LOCK:
row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
return json.loads(row[0]) if row else default
def set_setting(key, value):
with LOCK:
DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (key, json.dumps(value)))
DB.commit()
def objects(table):
with LOCK:
return [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
def get_object(table, key):
return next((o for o in objects(table) if o['id'] == key), None)
def audit(action, name=''):
with LOCK:
DB.execute('INSERT INTO audit (at,action,name) VALUES (?,?,?)',
(dt.datetime.now(dt.timezone.utc).isoformat(), action, name))
DB.execute('DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT 500)')
DB.commit()
def save_object(table, body):
with LOCK:
DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (body['id'], json.dumps(body)))
DB.commit()
def clean_text(value, limit=120, required=False):
if not isinstance(value, str) or len(value) > limit or any(ord(c) < 32 for c in value):
raise Problem('文本格式或长度不正确')
value = value.strip()
if required and not value:
raise Problem('请填写必填项')
return value
def integer(value, lo, hi):
if isinstance(value, bool) or not isinstance(value, int) or not lo <= value <= hi:
raise Problem('数值超出允许范围')
return value
def host_address(value):
try:
ip = ipaddress.ip_address(value)
if ip.version != 4 or ip.is_global or ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_reserved:
raise ValueError()
return str(ip)
except ValueError:
raise Problem('请填写局域网或 Tailscale IPv4 地址')
SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'owner',
'building', 'unit', 'floor', 'door', 'address', 'note')
def save_site(body):
key = body.get('id') or secrets.token_hex(8)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('空间编号不正确')
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home'))
if item['kind'] not in ('home', 'company', 'other'):
raise Problem('请选择家庭、公司或其他用途')
save_object('sites', item)
audit('保存空间', item['name'])
return item
def save_camera(body):
key = body.get('id') or secrets.token_hex(8)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('设备编号不正确')
old = get_object('cameras', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'point', 'username', 'model')}
asset = get_object('assets', body.get('assetId'))
if not asset:
raise Problem('请先建立并选择摄像头对象')
siblings = [c for c in objects('cameras') if c.get('assetId') == asset['id'] and c['id'] != key]
if len(siblings) >= asset['lensCount']:
raise Problem('该摄像头的镜头通道数已达到档案中设置的镜头数')
site = asset['siteId']
source_kind = body.get('sourceKind', 'direct')
recorder = get_object('recorders', body.get('recorderId')) if source_kind == 'recorder' else None
if source_kind not in ('direct', 'recorder') or (source_kind == 'recorder' and not recorder):
raise Problem('请选择录像机或直接接入')
item.update(assetId=asset['id'], sourceKind=source_kind, recorderId=recorder['id'] if recorder else '',
channelNumber=clean_text(body.get('channelNumber', ''), 30),
archiveSource=body.get('archiveSource', 'recorder' if recorder else 'local'))
if item['archiveSource'] not in ('local', 'recorder'):
raise Problem('录像存储来源不正确')
if item['archiveSource'] == 'recorder' and not recorder:
raise Problem('录像保存在录像机时,请先选择对应录像机')
item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')),
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535))
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
path = clean_text(body.get(k, default), 500, required=True)
if not path.startswith('/') or path.startswith('//') or '#' in path:
raise Problem('码流路径必须以一个 / 开头,不含 #')
item[k] = path
password = body.get('password', '')
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
raise Problem('设备密码格式不正确')
item['password'] = '' if recorder else password or old.get('password', '')
for k in ('enabled', 'record'):
if not isinstance(body.get(k, False), bool):
raise Problem('开关值不正确')
item[k] = body.get(k, False)
if not old and len(objects('cameras')) >= 32:
raise Problem('此版本最多配置 32 台设备')
save_object('cameras', item)
write_media_config()
audit('保存摄像头', item['name'])
return public_camera(item)
def save_asset(body):
key = body.get('id') or secrets.token_hex(8)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('对象编号不正确')
site = body.get('siteId')
if not get_object('sites', site):
raise Problem('请选择所属空间')
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')}
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16))
attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数')
with LOCK:
save_object('assets', item)
for c in attached:
c['siteId'] = site
save_object('cameras', c)
audit('保存摄像头对象', item['name'])
return item
def save_recorder(body):
key = body.get('id') or secrets.token_hex(8)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('录像机编号不正确')
old = get_object('recorders', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'username')}
site = body.get('siteId')
if not get_object('sites', site):
raise Problem('请选择录像机所属空间')
item.update(id=key, siteId=site, host=host_address(body.get('host', '')),
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'))
if item['driver'] not in ('generic-rtsp', 'hikvision-rtsp', 'tplink-rtsp', 'dahua-rtsp'):
raise Problem('接入驱动不正确')
password = body.get('password', '')
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
raise Problem('密码格式不正确')
item['password'] = password or old.get('password', '')
save_object('recorders', item)
write_media_config()
audit('保存录像机', item['name'])
return public_camera(item)
def public_camera(c):
return {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c['password'])}
def stream_name(c, quality='main'):
return 'cam_' + c['id'] + '_' + quality
def source(c, quality):
endpoint = get_object('recorders', c.get('recorderId')) if c.get('sourceKind') == 'recorder' else c
if not endpoint:
raise Problem('关联录像机不存在')
auth = (url.quote(endpoint['username'], safe='') + ':' + url.quote(endpoint['password'], safe='') + '@') if endpoint['username'] else ''
return 'rtsp://' + auth + endpoint['host'] + ':' + str(endpoint['port']) + c[quality + 'Path']
def write_media_config():
with LOCK:
paths = {}
policy = setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
for c in objects('cameras'):
for quality in ('main', 'sub'):
paths[stream_name(c, quality)] = {
'source': source(c, quality) if c['enabled'] else 'publisher',
'rtspTransport': 'tcp',
'sourceOnDemand': quality == 'sub' and c['enabled'],
'record': bool(c['enabled'] and c['record'] and quality == 'main' and not STORAGE['paused']),
'recordDeleteAfter': str(policy['retentionDays'] * 24) + 'h',
}
config = {
'logLevel': 'warn', 'logDestinations': ['stdout'],
'api': True, 'apiAddress': '127.0.0.1:' + str(API_PORT),
'rtsp': True, 'rtspAddress': '127.0.0.1:18554', 'rtspTransports': ['tcp'],
'rtmp': False, 'srt': False, 'webrtc': False, 'moq': False,
'hls': True, 'hlsAddress': '127.0.0.1:' + str(HLS_PORT), 'hlsVariant': 'fmp4',
'hlsSegmentDuration': '1s', 'hlsSegmentCount': 5, 'hlsAllowOrigins': [],
'playback': True, 'playbackAddress': '127.0.0.1:' + str(PLAYBACK_PORT),
'playbackAllowOrigins': [],
'authMethod': 'internal',
'authInternalUsers': [{'user': 'vision', 'pass': setting('mediaSecret'),
'ips': ['127.0.0.1', '::1'],
'permissions': [{'action': a} for a in ('read', 'api', 'playback')]}],
'pathDefaults': {'recordFormat': 'fmp4', 'recordPath': str(RECORDINGS / '%path' / '%Y-%m-%d_%H-%M-%S-%f'),
'recordSegmentDuration': '5m', 'recordPartDuration': '1s'},
'paths': paths,
}
raw = json.dumps(config, ensure_ascii=False, indent=2)
if CONFIG.exists() and CONFIG.read_text(encoding='utf8') == raw:
return
temp = DATA / 'mediamtx.tmp'
temp.write_text(raw, encoding='utf8')
os.chmod(temp, 0o600)
os.replace(temp, CONFIG)
def media_json(path, playback=False):
target = 'http://127.0.0.1:' + str(PLAYBACK_PORT if playback else API_PORT) + path
try:
req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH})
with urllib.request.urlopen(req, timeout=5) as r:
return json.load(r)
except Exception:
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
def status():
try:
data = media_json('/v3/paths/list?itemsPerPage=200')
ready = {p['name']: {'ready': bool(p.get('ready')), 'tracks': p.get('tracks', []),
'bytesReceived': p.get('bytesReceived', 0)} for p in data.get('items', [])}
media = True
except Problem:
ready, media = {}, False
cameras = []
for c in objects('cameras'):
if c.get('sourceKind') == 'recorder':
endpoint = get_object('recorders', c.get('recorderId'))
if endpoint:
c = dict(c, host=endpoint['host'], port=endpoint['port'])
main = ready.get(stream_name(c), {})
cameras.append({**public_camera(c), 'ready': main.get('ready', False), 'tracks': main.get('tracks', []),
'recordingRequested': c['enabled'] and c['record'] and not STORAGE['paused'],
'recordingActive': bool(c['enabled'] and c['record'] and not STORAGE['paused'] and main.get('ready', False)
and time.time() - RECENT_RECORDS.get(stream_name(c), 0) < 45)})
with LOCK:
storage = dict(STORAGE)
scan = dict(SCAN)
return {'cameras': cameras, 'assets': objects('assets'), 'recorders': [public_camera(r) for r in objects('recorders')],
'sites': objects('sites'), 'mediaOnline': media,
'storage': {**setting('storage'), **storage}, 'scan': scan,
'version': (ROOT / 'VERSION').read_text().strip(), 'uptime': round(time.time() - STARTED)}
def storage_watch():
while True:
try:
usage = shutil.disk_usage(RECORDINGS)
used, recent = 0, {}
for p in RECORDINGS.rglob('*.mp4'):
try:
info = p.stat()
used += info.st_size
recent[p.parent.name] = max(recent.get(p.parent.name, 0), info.st_mtime)
except FileNotFoundError:
pass
policy = setting('storage')
free_gb, used_gb = usage.free / 1e9, used / 1e9
reason = '录像空间已达到配额' if used_gb >= policy['maxGB'] else ('磁盘可用空间不足' if free_gb < policy['reserveGB'] else '')
with LOCK:
changed = STORAGE['paused'] != bool(reason)
STORAGE.update(freeGB=round(free_gb, 2), usedGB=round(used_gb, 2), paused=bool(reason), reason=reason)
RECENT_RECORDS.clear()
RECENT_RECORDS.update(recent)
if changed:
write_media_config()
audit('录像空间保护', reason or '空间恢复,继续按设备配置录像')
except Exception:
with LOCK:
STORAGE.update(paused=True, reason='存储状态读取失败')
try:
write_media_config()
except Exception:
pass
time.sleep(20)
def discover(network):
def probe(ip):
try:
with socket.create_connection((str(ip), 554), timeout=0.7) as s:
s.settimeout(1)
s.sendall(('OPTIONS rtsp://' + str(ip) + '/ RTSP/1.0\r\nCSeq: 1\r\n\r\n').encode())
response = s.recv(1024).decode(errors='replace').split('\r\n')[0]
if response.startswith('RTSP/'):
return {'host': str(ip), 'port': 554, 'response': response, 'authenticated': False}
except OSError:
return None
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=24) as pool:
items = [p for p in pool.map(probe, network.hosts()) if p]
with LOCK:
SCAN.update(items=items, at=dt.datetime.now(dt.timezone.utc).isoformat())
except Exception:
with LOCK:
SCAN['error'] = '探测未完成,请检查主机网络'
finally:
with LOCK:
SCAN['running'] = False
def parse_time(text):
try:
value = dt.datetime.fromisoformat(text.replace('Z', '+00:00'))
if value.tzinfo is None:
raise ValueError()
return value
except (ValueError, AttributeError):
raise Problem('时间必须包含时区')
class Handler(BaseHTTPRequestHandler):
server_version = 'ZhaoVision'
def log_message(self, *_):
pass # Do not put camera credentials, cookies or playback queries into logs.
def answer(self, value, code=200, cookie=None):
data = json.dumps(value, ensure_ascii=False).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
if cookie:
self.send_header('Set-Cookie', cookie)
self.end_headers()
self.wfile.write(data)
def token(self):
try:
c = http.cookies.SimpleCookie(self.headers.get('Cookie', ''))
return c['vision'].value if 'vision' in c else ''
except http.cookies.CookieError:
return ''
def authenticated(self):
token = self.token()
if not re.fullmatch('[a-f0-9]{64}', token):
return False
with LOCK:
return bool(DB.execute('SELECT 1 FROM sessions WHERE hash=? AND expires>?',
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone())
def require_auth(self):
if not self.authenticated():
raise Problem('请先登录', 401)
def body(self):
if self.headers.get_content_type() != 'application/json':
raise Problem('请求需要 JSON 格式')
n = int(self.headers.get('Content-Length', '0'))
if not 0 < n <= 16384:
raise Problem('请求长度不正确')
try:
data = json.loads(self.rfile.read(n))
if not isinstance(data, dict):
raise ValueError()
return data
except (ValueError, UnicodeError):
raise Problem('请求内容无法解析')
def validate_origin(self):
origin = self.headers.get('Origin')
if origin and url.urlsplit(origin).netloc != self.headers.get('Host'):
raise Problem('请从系统页面提交操作', 403)
def login(self, data, setup=False):
ip = self.client_address[0]
now = time.time()
with LOCK:
for k in list(ATTEMPTS):
if ATTEMPTS[k][1] < now:
del ATTEMPTS[k]
count, expiry = ATTEMPTS.get(ip, (0, now + 900))
ATTEMPTS[ip] = (count + 1, expiry)
if count >= 12:
raise Problem('尝试次数过多,请 15 分钟后重试', 429)
account = setting('account')
password = data.get('password', '')
if not isinstance(password, str) or not 10 <= len(password) <= 128:
raise Problem('管理密码需要 10 至 128 个字符')
if setup:
if account:
raise Problem('管理员已经初始化', 409)
code = data.get('code', '')
expected = (DATA / 'setup-code.txt').read_text().strip()
if not isinstance(code, str) or not hmac.compare_digest(code, expected):
raise Problem('初始化码不正确', 403)
salt = secrets.token_hex(16)
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
set_setting('account', {'salt': salt, 'hash': derived})
(DATA / 'setup-code.txt').unlink(missing_ok=True)
audit('初始化管理员')
else:
if not account:
raise Problem('请先初始化管理员', 409)
derived = hashlib.scrypt(password.encode(), salt=account['salt'].encode(), n=16384, r=8, p=1).hex()
if not hmac.compare_digest(derived, account['hash']):
raise Problem('密码不正确', 403)
token = secrets.token_hex(32)
DB.execute('DELETE FROM sessions WHERE expires<?', (now,))
DB.execute('INSERT INTO sessions VALUES (?,?)', (hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7))
DB.commit()
ATTEMPTS.pop(ip, None)
secure = '; Secure' if os.environ.get('VISION_SECURE_COOKIE') == '1' else ''
self.answer({'ok': True}, cookie='vision=' + token + '; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800' + secure)
def do_POST(self):
try:
self.validate_origin()
path = url.urlsplit(self.path).path
data = self.body()
if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path == '/api/logout':
with LOCK:
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
DB.commit()
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
elif path == '/api/sites':
self.answer(save_site(data))
elif path == '/api/cameras':
self.answer(save_camera(data))
elif path == '/api/assets':
self.answer(save_asset(data))
elif path == '/api/recorders':
self.answer(save_recorder(data))
elif path == '/api/storage':
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
'maxGB': integer(data.get('maxGB'), 5, 100000),
'reserveGB': integer(data.get('reserveGB'), 2, 1000)}
set_setting('storage', policy)
write_media_config()
audit('更新录像保留策略')
self.answer({'ok': True})
elif path == '/api/discover':
try:
network = ipaddress.ip_network(data.get('network', ''), strict=False)
if network.version != 4 or network.prefixlen < 24 or network.prefixlen > 30:
raise ValueError()
host_address(str(network.network_address))
except ValueError:
raise Problem('请输入最多 254 个地址的局域网网段,例如 192.168.1.0/24')
with LOCK:
if SCAN['running']:
raise Problem('探测正在进行', 409)
SCAN.update(running=True, items=[], error='', network=str(network))
threading.Thread(target=discover, args=(network,), daemon=True).start()
self.answer({'ok': True}, 202)
else:
raise Problem('接口不存在', 404)
except Problem as e:
self.answer({'error': str(e)}, e.status)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
def proxy(self, port, path):
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=25)
headers = {'Authorization': MEDIA_AUTH}
if self.headers.get('Range'):
headers['Range'] = self.headers['Range']
started = False
try:
conn.request('GET', path, headers=headers)
r = conn.getresponse()
if r.status >= 400:
raise Problem('暂时没有可播放画面,请确认设备网络、账号、码流路径和录像时间', 503)
self.send_response(r.status)
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
if r.getheader(k):
self.send_header(k, r.getheader(k))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.end_headers()
started = True
while True:
chunk = r.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except (OSError, http.client.HTTPException):
if not started:
raise Problem('视频服务连接失败', 503)
finally:
conn.close()
def do_GET(self):
try:
parsed = url.urlsplit(self.path)
path = parsed.path
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
if path == '/api/session':
self.answer({'authenticated': self.authenticated(), 'setupRequired': not bool(setting('account'))})
return
if path == '/healthz':
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
return
if path.startswith(('/api/', '/media/')):
self.require_auth()
if path == '/api/state':
self.answer(status())
elif path == '/api/audit':
with LOCK:
rows = [dict(r) for r in DB.execute('SELECT * FROM audit ORDER BY id DESC LIMIT 50')]
self.answer(rows)
elif path == '/api/recordings':
c = get_object('cameras', query.get('camera'))
if not c:
raise Problem('请选择摄像头')
start, end = parse_time(query.get('start')), parse_time(query.get('end'))
if not 0 < (end - start).total_seconds() <= 172800:
raise Problem('每次查询最多两天')
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'end': end.isoformat()})
raw = media_json('/list?' + q, playback=True)
items = [{'start': r['start'], 'duration': r['duration']} for r in raw]
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):
raise Problem('视频路径不正确')
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
elif path == '/media/playback':
c = get_object('cameras', query.get('camera'))
if not c:
raise Problem('摄像头不存在', 404)
start = parse_time(query.get('start'))
try:
duration = float(query.get('duration', '300'))
if not math.isfinite(duration) or not 0 < duration <= 3600:
raise ValueError()
except ValueError:
raise Problem('单次回放最多一小时')
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'duration': duration,
'format': 'mp4' if query.get('download') == '1' else 'fmp4'})
self.proxy(PLAYBACK_PORT, '/get?' + q)
else:
raise Problem('接口不存在', 404)
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css',
'/vendor/hls.min.js': 'vendor/hls.min.js'}
if path not in files:
raise Problem('页面不存在', 404)
file = ROOT / 'web' / files[path]
raw = file.read_bytes()
self.send_response(200)
self.send_header('Content-Type', (mimetypes.guess_type(file.name)[0] or 'application/octet-stream') + '; charset=utf-8')
self.send_header('Content-Length', str(len(raw)))
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'same-origin')
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
self.wfile.write(raw)
except Problem as e:
self.answer({'error': str(e)}, e.status)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
self.answer({'error': '请求未完成'}, 500)
def initialize():
global MEDIA_AUTH
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
set_setting('mediaSecret', secrets.token_hex(32))
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
if not setting('account') and not (DATA / 'setup-code.txt').exists():
(DATA / 'setup-code.txt').write_text(secrets.token_hex(12), encoding='utf8')
os.chmod(DATA / 'setup-code.txt', 0o600)
write_media_config()
if __name__ == '__main__':
initialize()
threading.Thread(target=storage_watch, daemon=True).start()
bind = os.environ.get('VISION_BIND', '127.0.0.1')
port = int(os.environ.get('VISION_PORT', '8790'))
server = ThreadingHTTPServer((bind, port), Handler)
server.daemon_threads = True
print('ZhaoVision listening on ' + bind + ':' + str(port), flush=True)
server.serve_forever()
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
# Run from a committed, published release checkout on an x86_64 Ubuntu host.
# VISION_BIND is the host's Tailscale IPv4; defaults to loopback.
# MEDIA_ARCHIVE optionally points to an already downloaded pinned release archive.
[[ $(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
release_dir=$(cd -- "$(dirname -- "$0")/.." && pwd)
bind_address=${VISION_BIND:-127.0.0.1}
python3 - "$bind_address" <<'PY'
import ipaddress,sys
ip=ipaddress.ip_address(sys.argv[1])
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'
PY
getent passwd zhaovision >/dev/null || useradd --system --home-dir /var/lib/zhaovision --shell /usr/sbin/nologin zhaovision
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}
if [[ ! -f "$archive" ]]; then
curl -fL --retry 3 'https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_linux_amd64.tar.gz' -o "$archive"
fi
python3 - "$archive" <<'PY'
import hashlib,sys
expected='653abc672a3e693f8d3b2717752492fdcfb8072291ec108d03d3dd857411b0ee'
assert hashlib.file_digest(open(sys.argv[1],'rb'),'sha256').hexdigest()==expected, 'MediaMTX checksum mismatch'
PY
tar -xzf "$archive" -C /usr/local/lib/zhaovision mediamtx LICENSE
chmod 755 /usr/local/lib/zhaovision/mediamtx
if [[ -e /opt/zhaovision/current && ! -L /opt/zhaovision/current ]]; then
echo '/opt/zhaovision/current is not a symlink; refusing to replace it'; exit 1
fi
ln -sfn "$release_dir" /opt/zhaovision/current
if [[ ! -f /etc/zhaovision.env ]]; then
umask 077
printf 'VISION_BIND=%s\nVISION_PORT=8790\nVISION_DATA=/var/lib/zhaovision\nVISION_RECORDINGS=/var/lib/zhaovision/recordings\n' "$bind_address" > /etc/zhaovision.env
fi
install -m 644 "$release_dir/deploy/zhaovision.service" /etc/systemd/system/zhaovision.service
install -m 644 "$release_dir/deploy/zhaovision-media.service" /etc/systemd/system/zhaovision-media.service
systemctl daemon-reload
systemctl enable zhaovision.service zhaovision-media.service
systemctl restart zhaovision.service
for attempt in {1..20}; do
[[ -s /var/lib/zhaovision/mediamtx.yml ]] && break
sleep 1
done
systemctl restart zhaovision-media.service
echo 'Services started. Initial setup code: /var/lib/zhaovision/setup-code.txt'
echo "Tailscale entry: http://${bind_address}:8790/"
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=ZhaoVision MediaMTX video and recording service
After=zhaovision.service
Requires=zhaovision.service
[Service]
Type=simple
User=zhaovision
Group=zhaovision
WorkingDirectory=/var/lib/zhaovision
Environment=TZ=UTC
ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml
Restart=always
RestartSec=4
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/zhaovision
MemoryMax=1200M
[Install]
WantedBy=multi-user.target
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=ZhaoVision local video management
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
Type=simple
User=zhaovision
Group=zhaovision
WorkingDirectory=/opt/zhaovision/current
EnvironmentFile=/etc/zhaovision.env
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PYTHONUNBUFFERED=1
Environment=TZ=UTC
ExecStart=/usr/bin/python3 /opt/zhaovision/current/app.py
Restart=always
RestartSec=4
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/zhaovision
MemoryMax=512M
[Install]
WantedBy=multi-user.target
+135
View File
@@ -0,0 +1,135 @@
'use strict';
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer;
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) {
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); }
if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; stopPlayers(); auth(); } throw Error(result.error || '操作未完成'); }
return result;
}
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
function siteName(id) { return state.sites.find(s => s.id === id)?.name || '未关联空间'; }
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
function filtered() { return state.cameras.filter(c => !$('#site-filter').value || c.siteId === $('#site-filter').value); }
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
function showPage(page) {
if (activePage !== page) stopPlayers();
activePage = page;
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
if (page !== 'playback') $('#play-video').pause();
render();
if (page === 'storage') loadAudit();
}
function render() {
$('#metric-all').textContent = state.assets.length;
$('#metric-ready').textContent = state.cameras.filter(c=>c.ready).length;
$('#metric-sites').textContent = state.sites.length;
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
$('#service-status').textContent = state.mediaOnline ? '视频服务已连接' : '视频服务未连接';
$('#service-status').classList.toggle('off', !state.mediaOnline);
$('#version').textContent = 'v' + state.version + ' / 数据存于本机';
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
$('#banner').textContent = warning; $('#banner').hidden = !warning;
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind) + ' · ' + s.name)}</option>`).join(''));
if (activePage === 'live') renderLive();
if (activePage === 'cameras') renderCameras();
if (activePage === 'sites') renderSites();
if (activePage === 'objects') renderAssets();
if (activePage === 'recorders') renderRecorders();
if (activePage === 'playback') {
setOptions($('#play-camera'), filtered().map(c=>`<option value="${c.id}">${esc(siteName(c.siteId)+' / '+c.name)}</option>`).join(''));
drawTimeline();
}
if (activePage === 'storage') renderStorage();
}
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
function renderLive() {
const cameras = filtered(), quality = $('#quality').value;
const key = JSON.stringify(cameras.map(c=>[c.id,c.name,c.siteId,c.point,c.ready,c.enabled,c.record,c.recordingActive])) + quality;
if (key === liveKey) return;
stopPlayers(); liveKey = key;
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button></div></article>`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>');
for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement;
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。');
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();}});
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(()=>{});}
else error('当前浏览器不支持 HLS 播放,请使用较新的浏览器。');
}
}
function renderCameras() {
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
const cameras=filtered();
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
}
function renderSites() {
$('#site-list').innerHTML=state.sites.length?state.sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.cameras.filter(c=>c.siteId===s.id).length} 台</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
}
function renderStorage() {
const s=state.storage;
if (!$('#storage-form').contains(document.activeElement)) for(const k of ['retentionDays','maxGB','reserveGB']) $('#storage-form').elements[k].value=s[k]??'';
$('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
}
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){toast(e.message);} }
async function refresh() {if(!loggedIn)return;try{state=await api('/api/state');render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}}
async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-title').textContent=setup?'建立你的视觉空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置你自己的管理密码。':'输入管理密码,进入视觉工作空间。';if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}}
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
handleForm('site-form','/api/sites');
handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enabled:f.elements.enabled.checked,record:f.elements.record.checked}));
handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit);
$('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();$('#play-video').pause();await auth();};
$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
function resetPlayback() {const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';drawTimeline();$('#recording-list').innerHTML='';}
async function queryRecordings() {
resetPlayback();const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;}
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
$('#play-info').textContent='正在查找录像…';
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){$('#play-info').textContent=e.message;}
}
function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}}
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 当前片段最多一小时';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
$('#query-recordings').onclick=queryRecordings;$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const rect=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-rect.left)/rect.width));};
$('#play-video').onerror=()=>{$('#play-info').textContent='回放失败:可能没有完整录像,或浏览器不支持录像编码;可下载片段后播放。';};
const today=new Date();$('#play-date').value=[today.getFullYear(),String(today.getMonth()+1).padStart(2,'0'),String(today.getDate()).padStart(2,'0')].join('-');
window.addEventListener('resize',drawTimeline);window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);auth();
function renderAssets(){
$('#asset-list').innerHTML=state.assets.length?state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value).map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
}
function renderRecorders(){
$('#recorder-list').innerHTML=state.recorders.length?state.recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css">
<script defer src="/vendor/hls.min.js"></script><script defer src="/app.js"></script>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="/"><span class="brand-icon">◉</span><span>赵府智家<small>VISION / 本地视觉系统</small></span></a>
<div class="nav-label">工作空间</div>
<nav aria-label="主导航">
<button data-page="live" class="active">◫ <span>实时画面</span></button>
<button data-page="playback">◷ <span>录像回放</span></button>
<button data-page="objects">▣ <span>摄像头对象</span></button><button data-page="recorders">▤ <span>录像机</span></button><button data-page="cameras">◫ <span>镜头通道</span></button>
<button data-page="sites">⌂ <span>空间档案</span></button>
<button data-page="storage">▤ <span>录像与存储</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><div><div class="eyebrow">ZHAOFU HOME / VISION</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<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-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>
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载当前片段</a></div></div>
<div id="recording-list" class="recording-list"></div>
</section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>CHANNELS</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></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-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>
<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>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<div id="toast" role="status" hidden></div>
</body>
</html>
+2
View File
File diff suppressed because one or more lines are too long
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
{
"Apache-2.0.txt": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
"hls.LICENSE": "ca8773cf798c7ed997d4dd7c8e23c348699f8d5b7462636694cc14de6cda12db",
"hls.min.js": "a12e7ee1cd64a69dcdb314157e45dafcba705bfb0b1440b7935cb265d374423e"
}
+28
View File
@@ -0,0 +1,28 @@
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+4
View File
File diff suppressed because one or more lines are too long