feat: local vision service with structured spaces and camera lens objects
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
web/vendor/* -text
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
data/
|
||||
recordings/
|
||||
runtime/
|
||||
*.db*
|
||||
*.log
|
||||
*.key
|
||||
*.env
|
||||
__pycache__/
|
||||
.venv/
|
||||
mediamtx
|
||||
mediamtx.exe
|
||||
*.tar.gz
|
||||
|
||||
@@ -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.
|
||||
@@ -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 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
|
||||
@@ -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()
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => [...document.querySelectorAll(s)];
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)}));
|
||||
@@ -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>
|
||||
File diff suppressed because one or more lines are too long
Vendored
+202
@@ -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.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Apache-2.0.txt": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
||||
"hls.LICENSE": "ca8773cf798c7ed997d4dd7c8e23c348699f8d5b7462636694cc14de6cda12db",
|
||||
"hls.min.js": "a12e7ee1cd64a69dcdb314157e45dafcba705bfb0b1440b7935cb265d374423e"
|
||||
}
|
||||
Vendored
+28
@@ -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.
|
||||
Vendored
+4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user