Add camera presets snapshots and continuous recording playback
This commit is contained in:
@@ -49,7 +49,7 @@ python3 app.py
|
||||
mediamtx ./data/mediamtx.yml
|
||||
```
|
||||
|
||||
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
|
||||
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
|
||||
|
||||
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。
|
||||
|
||||
@@ -167,3 +167,15 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
|
||||
### 实机验收完善(v0.1.20)
|
||||
|
||||
摄像机信息及参数改为紧凑布局;全屏监控隐藏管理参数入口。读取或保存时暂时锁定参数输入,避免正在编辑的值被返回结果覆盖。视频服务错误响应也会正确关闭,修复新 Python 版本提示的资源未释放问题。
|
||||
|
||||
|
||||
## 云台常用位置、截图与录像续播(v0.1.21)
|
||||
|
||||
- “云台控制”中读取设备预置位。管理员可以保存当前位置、删除位置;有对应空间与云台权限的个人账号可以查看和转到已有位置。保存和删除前检查版本,保存后回读设备列表。位置保存在摄像机中,双镜头可能共用同一云台。名称最多 40 字。本版不提供覆盖已有位置:实测 H9c 当前固件拒绝标准更新请求,调整后可另存新位置,再由管理员决定是否删除旧位置。
|
||||
- 云台操作按物理设备 / 节点互斥。预置位转动后查询设备状态,观察窗口为 10 秒,单次设备请求另有 5 秒超时;结束、超时或异常都尝试发送 Stop。停止按钮不排队,可取消正在等待的转动。设备网络失联时无法保证 Stop 送达,界面会明确提示核对画面。关闭面板或切换后台时,对正在执行的操作尝试停止。预置位位置和设备能力仍以具体型号、固件实际返回为准。
|
||||
- 实时画面与录像回放增加“截图”。截取当前播放器已解码的一帧,可预览并下载 PNG。图片仅在当前浏览器内存中生成,不上传到服务器,关闭后释放。截图分辨率取决于当前预览码流;截取时间不是摄像机源时间,仍有播放缓冲延迟。
|
||||
- 本机录像查询按所选日期裁剪、排序与合并重叠范围。每次转换最多一小时,播放结束可以继续下一段;真实空档会跳过并提示。可取消自动续播。未正常播完的片段不会自动略过,主动暂停不会触发续播。
|
||||
- 录像页显示实际录像时间,支持以当前播放位置标记片段起止、手动修改起止时间、生成原始编码下载链接。片段最长一小时,不能跨越无录像空档;边界受原始关键帧影响,非逐帧无损剪辑。自动续播保留正在填写的自定义片段。
|
||||
- 本版不创建新的家庭数据或修改已有账户和录像策略;新增界面及设备操作继续使用既有空间、管理与云台权限。录像机原有历史录像仍未适配,以上续播与片段工具用于小电脑本机录像。
|
||||
|
||||
预置位协议依据:[ONVIF PTZ Service 5.4](https://www.onvif.org/specs/srv/ptz/ONVIF-PTZ-Service-Spec.pdf)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。
|
||||
|
||||
@@ -597,7 +597,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self.require_auth()
|
||||
if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
|
||||
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'):
|
||||
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'):
|
||||
self.require_admin()
|
||||
if path == '/api/logout':
|
||||
with LOCK:
|
||||
@@ -619,6 +619,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502)
|
||||
audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username'])
|
||||
self.answer(result)
|
||||
elif path == '/api/ptz/presets':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
|
||||
if data.get('action') != 'goto':
|
||||
self.require_admin()
|
||||
try:
|
||||
result = ptz.CONTROLLER.preset_command(c, data)
|
||||
except ValueError as e:
|
||||
raise Problem(str(e), 400)
|
||||
except Exception:
|
||||
raise Problem('设备操作未确认,请核对画面并重新读取预置位,勿重复提交;转动异常可点击停止', 502)
|
||||
audit('云台预置位 ' + data['action'], c['name'] + ' / ' + self.user['username'])
|
||||
self.answer(result)
|
||||
elif path == '/api/ptz':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
|
||||
try:
|
||||
@@ -778,6 +790,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == '/api/users':
|
||||
self.require_admin()
|
||||
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
|
||||
elif path == '/api/ptz/presets':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
|
||||
try:
|
||||
result, _ = ptz.CONTROLLER.read_presets(c)
|
||||
except ValueError as e:
|
||||
raise Problem(str(e), 400)
|
||||
except Exception:
|
||||
raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502)
|
||||
self.answer(dict(result, canManage=self.user['role'] == 'admin'))
|
||||
elif path == '/api/ptz':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
|
||||
try:
|
||||
@@ -858,6 +879,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
'/family-events.js': 'family-events.js',
|
||||
'/family-history.js': 'family-history.js',
|
||||
'/camera-settings.js': 'camera-settings.js',
|
||||
'/recording-ranges.js': 'recording-ranges.js', '/recording-ui.js': 'recording-ui.js', '/snapshot.js': 'snapshot.js',
|
||||
'/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js',
|
||||
'/zhao-icon.png': 'zhao-icon.png',
|
||||
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
||||
@@ -873,7 +895,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; 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-store' if path == '/' else 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
@@ -4,6 +4,7 @@ import datetime as dt
|
||||
import hashlib
|
||||
import secrets
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
@@ -39,8 +40,12 @@ def request(host, port, endpoint, namespace, operation, content, username, passw
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPDigestAuthHandler(passwords))
|
||||
req = urllib.request.Request(target, data=envelope.encode(), headers={
|
||||
'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
root = ET.fromstring(response.read(1024 * 1024))
|
||||
try:
|
||||
with opener.open(req, timeout=5) as response:
|
||||
root = ET.fromstring(response.read(1024 * 1024))
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
raise
|
||||
if root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') is not None:
|
||||
raise ValueError('ONVIF returned a fault; check device credentials and permissions')
|
||||
return root
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement."""
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse as url
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
import onvif
|
||||
|
||||
@@ -17,6 +19,100 @@ class Controller:
|
||||
self.guard = threading.RLock()
|
||||
self.cache = {}
|
||||
self.locks = {}
|
||||
self.cancellations = {}
|
||||
self.preset_versions = {}
|
||||
|
||||
def node_key(self, c, info):
|
||||
return c['host'], info['endpoint'][0], info['node']
|
||||
|
||||
def read_presets(self, c):
|
||||
info = self.discover(c)
|
||||
node = self.call(c, info['endpoint'], PTZ, 'GetNode',
|
||||
'<m:NodeToken>' + escape(info['node']) + '</m:NodeToken>')
|
||||
maximum = int(node.findtext('.//' + T + 'MaximumNumberOfPresets', '0'))
|
||||
if not 0 < maximum <= 1024:
|
||||
raise ValueError('设备未开放预置位功能')
|
||||
root = self.call(c, info['endpoint'], PTZ, 'GetPresets',
|
||||
'<m:ProfileToken>' + escape(info['profile']) + '</m:ProfileToken>')
|
||||
items = []
|
||||
for item in root.findall('.//{' + PTZ + '}Preset'):
|
||||
token = item.get('token', '')
|
||||
if not token or len(token) > 256 or any(p['token'] == token for p in items):
|
||||
raise ValueError('设备返回了无效预置位列表')
|
||||
items.append(dict(token=token, name=item.findtext(T + 'Name', '') or '未命名位置'))
|
||||
positions = [ET.tostring(p, encoding='unicode') for p in root.findall('.//{' + PTZ + '}Preset')]
|
||||
with self.guard:
|
||||
version = self.preset_versions.get(self.node_key(c, info), 0)
|
||||
fingerprint = [info['profile'], info['node'], maximum, sorted(items, key=lambda p: p['token']), sorted(positions), version]
|
||||
revision = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()
|
||||
return dict(items=items, maximum=maximum, revision=revision,
|
||||
note='预置位保存在摄像机中,两个镜头可能共用云台和位置。'), info
|
||||
|
||||
def preset_command(self, c, data):
|
||||
action = data.get('action')
|
||||
if action not in ('create', 'remove', 'goto'):
|
||||
raise ValueError('请选择保存当前位置、删除或转到预置位')
|
||||
info = self.discover(c)
|
||||
key = self.node_key(c, info)
|
||||
with self.guard:
|
||||
lock = self.locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ValueError('云台正在执行操作,请稍后再点')
|
||||
try:
|
||||
snapshot, info = self.read_presets(c)
|
||||
if data.get('revision') != snapshot['revision']:
|
||||
raise ValueError('预置位已变化,请重新读取后操作')
|
||||
token = data.get('token')
|
||||
if action != 'create' and not any(p['token'] == token for p in snapshot['items']):
|
||||
raise ValueError('预置位不存在,请重新读取')
|
||||
body = '<m:ProfileToken>' + escape(info['profile']) + '</m:ProfileToken>'
|
||||
if action == 'goto':
|
||||
cancel = threading.Event()
|
||||
with self.guard:
|
||||
self.cancellations[key] = cancel
|
||||
try:
|
||||
# A finite preset move has no ONVIF Timeout parameter. Bound it on
|
||||
# the server and send Stop even after an ambiguous network failure.
|
||||
self.call(c, info['endpoint'], PTZ, 'GotoPreset', body + '<m:PresetToken>' + escape(token) + '</m:PresetToken>')
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
if cancel.wait(0.5):
|
||||
return dict(ok=True, completed=False, message='本次预置位转动已停止')
|
||||
status = self.call(c, info['endpoint'], PTZ, 'GetStatus', body)
|
||||
if status.findtext('.//' + T + 'MoveStatus/' + T + 'PanTilt') == 'IDLE':
|
||||
return dict(ok=True, completed=True, message='设备报告转动结束,请核对画面位置')
|
||||
return dict(ok=True, completed=False, message='设备未确认转动结束,已发送停止;请核对画面后重试')
|
||||
finally:
|
||||
try:
|
||||
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt><m:Zoom>true</m:Zoom>')
|
||||
finally:
|
||||
with self.guard:
|
||||
self.cancellations.pop(key, None)
|
||||
if action == 'create':
|
||||
name = data.get('name')
|
||||
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 40 or any(ord(x) < 32 for x in name):
|
||||
raise ValueError('位置名称需要 1–40 个字且不包含控制字符')
|
||||
name = name.strip()
|
||||
if any(p['name'].casefold() == name.casefold() for p in snapshot['items']):
|
||||
raise ValueError('位置名称已存在,请换一个名称')
|
||||
if len(snapshot['items']) >= snapshot['maximum']:
|
||||
raise ValueError('设备预置位已满')
|
||||
payload = body + '<m:PresetName>' + escape(name) + '</m:PresetName>'
|
||||
with self.guard:
|
||||
self.preset_versions[key] = self.preset_versions.get(key, 0) + 1
|
||||
response = self.call(c, info['endpoint'], PTZ, 'SetPreset', payload)
|
||||
saved_token = response.findtext('.//{' + PTZ + '}PresetToken')
|
||||
else:
|
||||
with self.guard:
|
||||
self.preset_versions[key] = self.preset_versions.get(key, 0) + 1
|
||||
self.call(c, info['endpoint'], PTZ, 'RemovePreset', body + '<m:PresetToken>' + escape(token) + '</m:PresetToken>')
|
||||
latest, _ = self.read_presets(c)
|
||||
verified = (not any(p['token'] == token for p in latest['items']) if action == 'remove'
|
||||
else any(p['token'] == saved_token and p['name'] == name for p in latest['items']))
|
||||
return dict(ok=True, verified=verified, presets=latest,
|
||||
message='设备预置位列表已回读确认' if verified else '设备响应与预置位回读不一致,请核对后再操作')
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def call(self, c, endpoint, namespace, operation, body=''):
|
||||
port, path = endpoint
|
||||
@@ -98,6 +194,10 @@ class Controller:
|
||||
def stop():
|
||||
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt>')
|
||||
if action == 'stop':
|
||||
with self.guard:
|
||||
cancel = self.cancellations.get(self.node_key(c, info))
|
||||
if cancel:
|
||||
cancel.set()
|
||||
stop()
|
||||
return {'ok': True, 'stopped': True}
|
||||
# Lock by physical host/node, including commands sent through another lens/account.
|
||||
|
||||
+28
-1
@@ -59,6 +59,33 @@ class WebTests(unittest.TestCase):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.request(path, False)[0], 401)
|
||||
|
||||
def test_presets_api_enforces_control_and_management_roles(self):
|
||||
camera = dict(id='f'*16, name='Preset test', siteId='preset-site', enabled=True)
|
||||
app.save_object('sites', dict(id='preset-site', name='Preset site'))
|
||||
app.save_object('cameras', camera)
|
||||
viewer = app.ACCOUNTS.save(dict(username='preset-viewer', password='test-password', siteIds=['preset-site'], ptzControl=True), self.admin)
|
||||
token = '2'*64
|
||||
with app.LOCK:
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, viewer['id']))
|
||||
app.DB.commit()
|
||||
path='/api/ptz/presets'
|
||||
payload=dict(camera=camera['id'],action='goto',token='1',revision='v1')
|
||||
with patch.object(app.ptz.CONTROLLER,'read_presets',return_value=({'items':[]},{})),patch.object(app.ptz.CONTROLLER,'preset_command',return_value={'ok':True}) as command:
|
||||
self.assertEqual(self.request(path+'?camera='+camera['id'],False)[0],401)
|
||||
code,_,body=self.request(path+'?camera='+camera['id'],token=token)
|
||||
self.assertEqual(code,200);self.assertFalse(json.loads(body)['canManage'])
|
||||
self.assertEqual(self.request(path,data=payload,token=token)[0],200)
|
||||
for action in ['create','replace','remove']:
|
||||
self.assertEqual(self.request(path,data=dict(payload,action=action),token=token)[0],403)
|
||||
self.assertEqual(command.call_count,1)
|
||||
self.assertEqual(self.request(path,data=dict(payload,action='create'))[0],200)
|
||||
self.assertEqual(self.request(path,data=payload,origin='https://other.example')[0],403)
|
||||
app.ACCOUNTS.save(dict(viewer,ptzControl=False),self.admin)
|
||||
with app.LOCK:
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, viewer['id']))
|
||||
app.DB.commit()
|
||||
self.assertEqual(self.request(path+'?camera='+camera['id'],token=token)[0],403)
|
||||
|
||||
def test_static_security_headers(self):
|
||||
code, headers, _ = self.request('/', False)
|
||||
self.assertEqual(code, 200)
|
||||
@@ -78,7 +105,7 @@ class WebTests(unittest.TestCase):
|
||||
self.assertEqual(headers['Cache-Control'], 'no-store')
|
||||
self.assertNotIn(b'__ASSET_VERSION__', body)
|
||||
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
|
||||
self.assertEqual(len(assets), 15)
|
||||
self.assertEqual(len(assets), 18)
|
||||
version = (app.ROOT / 'VERSION').read_text().strip()
|
||||
for asset in assets:
|
||||
self.assertTrue(asset.endswith('?v=' + version))
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
from xml.etree.ElementTree import fromstring
|
||||
import ptz
|
||||
|
||||
|
||||
def xml(body):
|
||||
return fromstring('<root xmlns:t="' + ptz.onvif.SCHEMA + '" xmlns:p="' + ptz.PTZ + '">' + body + '</root>')
|
||||
|
||||
|
||||
class PresetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ctrl = ptz.Controller()
|
||||
self.camera = dict(host='192.168.0.2', username='admin', password='example', mainPath='/stream1')
|
||||
self.info = dict(endpoint=(80, '/onvif/PTZ'), profile='p1', node='shared')
|
||||
self.snapshot = dict(revision='v1', maximum=12, items=[dict(token='1', name='Door')])
|
||||
|
||||
def test_read_presets_validates_device_and_list(self):
|
||||
node = xml('<t:MaximumNumberOfPresets>12</t:MaximumNumberOfPresets>')
|
||||
items = xml('<p:Preset token="1"><t:Name>Door</t:Name></p:Preset>')
|
||||
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', side_effect=[node, items]):
|
||||
result, _ = self.ctrl.read_presets(self.camera)
|
||||
self.assertEqual(result['items'], self.snapshot['items'])
|
||||
self.assertEqual(len(result['revision']), 64)
|
||||
self.assertNotIn('endpoint', result)
|
||||
for response in [xml('<t:MaximumNumberOfPresets>0</t:MaximumNumberOfPresets>'), xml('<t:MaximumNumberOfPresets>999999</t:MaximumNumberOfPresets>')]:
|
||||
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', return_value=response):
|
||||
with self.assertRaises(ValueError): self.ctrl.read_presets(self.camera)
|
||||
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', side_effect=[node, xml('<p:Preset token="1"/><p:Preset token="1"/>')]):
|
||||
with self.assertRaises(ValueError): self.ctrl.read_presets(self.camera)
|
||||
|
||||
def command(self, data, latest=None, response=None):
|
||||
versions = [(self.snapshot, self.info)]
|
||||
if latest is not None: versions.append((latest, self.info))
|
||||
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'read_presets', side_effect=versions), patch.object(self.ctrl, 'call', return_value=response) as call:
|
||||
result = self.ctrl.preset_command(self.camera, dict(revision='v1', **data))
|
||||
return result, call
|
||||
|
||||
def test_creation_escapes_name_and_confirms_returned_token(self):
|
||||
latest = dict(self.snapshot, items=[*self.snapshot['items'], dict(token='2', name='Yard & Gate')])
|
||||
result, call = self.command(dict(action='create', name='Yard & Gate'), latest, xml('<p:PresetToken>2</p:PresetToken>'))
|
||||
self.assertTrue(result['verified'])
|
||||
self.assertIn('Yard & Gate', call.call_args.args[4])
|
||||
self.assertNotIn('<m:PresetToken>', call.call_args.args[4])
|
||||
|
||||
def test_stale_snapshot_and_unknown_token_cannot_write(self):
|
||||
for data in [dict(action='create', name='Yard', revision='old'), dict(action='goto', token='unknown', revision='v1')]:
|
||||
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'read_presets', return_value=(self.snapshot,self.info)), patch.object(self.ctrl, 'call') as call:
|
||||
with self.assertRaises(ValueError): self.ctrl.preset_command(self.camera, data)
|
||||
call.assert_not_called()
|
||||
self.assertFalse(self.ctrl.locks[self.ctrl.node_key(self.camera,self.info)].locked())
|
||||
|
||||
def test_invalid_names_duplicate_names_and_full_device_rejected(self):
|
||||
for name in ['', 'x'*41, 'bad\nname', None, ' door ']:
|
||||
with self.assertRaises(ValueError): self.command(dict(action='create', name=name))
|
||||
self.snapshot['maximum']=1
|
||||
with self.assertRaises(ValueError): self.command(dict(action='create',name='Yard'))
|
||||
|
||||
def test_remove_readback_and_unsupported_replace_rejected(self):
|
||||
result,call=self.command(dict(action='remove',token='1'),dict(self.snapshot,items=[]))
|
||||
self.assertTrue(result['verified']);self.assertEqual(call.call_args.args[3],'RemovePreset')
|
||||
with patch.object(self.ctrl,'call') as call:
|
||||
with self.assertRaises(ValueError):self.ctrl.preset_command(self.camera,dict(action='replace',token='1',name='Door'))
|
||||
call.assert_not_called()
|
||||
|
||||
def test_ignored_write_is_not_reported_as_verified(self):
|
||||
result,_=self.command(dict(action='create',name='Yard'),self.snapshot,xml('<p:PresetToken>2</p:PresetToken>'))
|
||||
self.assertFalse(result['verified'])
|
||||
|
||||
def test_readback_failure_releases_lock_and_is_not_retried(self):
|
||||
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',side_effect=[(self.snapshot,self.info),OSError()]),patch.object(self.ctrl,'call') as call:
|
||||
with self.assertRaises(OSError):self.ctrl.preset_command(self.camera,dict(action='remove',token='1',revision='v1'))
|
||||
self.assertEqual(call.call_count,1)
|
||||
self.assertFalse(self.ctrl.locks[self.ctrl.node_key(self.camera,self.info)].locked())
|
||||
|
||||
def test_goto_polls_device_and_always_stops(self):
|
||||
status=xml('<t:MoveStatus><t:PanTilt>IDLE</t:PanTilt></t:MoveStatus>')
|
||||
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',side_effect=[None,status,None]) as call,patch('ptz.threading.Event',return_value=Mock(wait=Mock(return_value=False))):
|
||||
result=self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
|
||||
self.assertTrue(result['completed'])
|
||||
self.assertEqual([c.args[3] for c in call.call_args_list],['GotoPreset','GetStatus','Stop'])
|
||||
self.assertFalse(self.ctrl.cancellations)
|
||||
|
||||
def test_goto_network_failure_still_attempts_stop(self):
|
||||
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',side_effect=[OSError(),None]) as call:
|
||||
with self.assertRaises(OSError):self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
|
||||
self.assertEqual(call.call_args.args[3],'Stop');self.assertFalse(self.ctrl.cancellations)
|
||||
|
||||
def test_timeout_reports_incomplete_and_stops(self):
|
||||
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',return_value=xml('<t:MoveStatus><t:PanTilt>MOVING</t:PanTilt></t:MoveStatus>')) as call,patch('ptz.time.monotonic',side_effect=[0,0,11]),patch('ptz.threading.Event',return_value=Mock(wait=Mock(return_value=False))):
|
||||
result=self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
|
||||
self.assertFalse(result['completed']);self.assertEqual(call.call_args.args[3],'Stop')
|
||||
|
||||
def test_shared_node_blocks_preset_writes_but_stop_cancels(self):
|
||||
key=self.ctrl.node_key(self.camera,self.info);lock=threading.Lock();lock.acquire();self.ctrl.locks[key]=lock
|
||||
event=threading.Event();self.ctrl.cancellations[key]=event
|
||||
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'call'):
|
||||
with self.assertRaises(ValueError):self.ctrl.preset_command(self.camera,dict(action='create',name='Yard'))
|
||||
self.ctrl.command(self.camera,'stop');self.assertTrue(event.is_set())
|
||||
lock.release()
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),Ranges=require('./web/recording-ranges.js');
|
||||
const start=Date.parse('2026-10-04T00:00:00Z'),date=n=>new Date(start+n*1000);
|
||||
test('ranges clip to selected day and merge overlaps without filling gaps',()=>{
|
||||
const items=[{start:date(-10),duration:40},{start:date(20),duration:30},{start:date(60),duration:100},{start:'invalid',duration:20},{start:date(3),duration:-4},{start:date(3),duration:Infinity}];
|
||||
assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]);
|
||||
});
|
||||
test('long recording continues after each one-hour session',()=>{
|
||||
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)});
|
||||
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null);
|
||||
});
|
||||
test('next segment skips the actual gap and does not replay the previous end',()=>{
|
||||
const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null);
|
||||
assert.deepEqual(Ranges.next(ranges,date(10)),{start:+date(30),end:+date(40)});
|
||||
});
|
||||
test('clip requires valid, positive, fully recorded interval up to one hour',()=>{
|
||||
const r=[{start:+date(0),end:+date(4000)},{start:+date(5000),end:+date(8000)}];
|
||||
for(const [a,b] of [[date(1),date(1)],[date(4),date(3)],[date(0),date(3601)],[date(3900),date(5100)],[date(-1),date(5)],[new Date(NaN),date(5)]])assert.throws(()=>Ranges.clip(r,a,b));
|
||||
assert.deepEqual(Ranges.clip(r,date(0),date(3600)),{start:+date(0),end:+date(3600)});
|
||||
});
|
||||
function section(file,a,b){const s=fs.readFileSync('web/'+file,'utf8'),i=s.indexOf(a),j=s.indexOf(b,i+a.length);assert.ok(i>=0&&j>i);return s.slice(i,j);}
|
||||
function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};}
|
||||
function ui(extra={}){const elements=new Map();const $=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){},reset(){},value:'',hidden:false});return elements.get(s);};return vm.createContext({$,Number,Date,URLSearchParams,loggedIn:true,activePage:'playback',RecordingRanges:Ranges,...extra});}
|
||||
test('auto-continue preserves user choice, stops short clips and ends at last segment',()=>{
|
||||
const c=ui({playbackRange:{start:0,end:10000},spans:[{start:0,end:10000},{start:20000,end:30000}],playAt:d=>c.played=+d});
|
||||
vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c);
|
||||
c.$('#play-video').ended=true;c.$('#play-video').currentTime=10;c.$('#play-autonext').checked=false;c.continuePlayback();assert.equal(c.played,undefined);
|
||||
c.$('#play-autonext').checked=true;c.$('#play-video').currentTime=1;c.continuePlayback();assert.match(c.$('#play-info').textContent,/提前结束/);assert.equal(c.played,undefined);
|
||||
c.$('#play-video').currentTime=10;c.continuePlayback();assert.equal(c.played,20000);assert.match(c.$('#play-info').textContent,/跳过/);
|
||||
c.playbackRange={start:20000,end:30000};c.continuePlayback();assert.match(c.$('#play-info').textContent,/播放完毕/);
|
||||
});
|
||||
test('late recording list cannot replace another camera or a logged-out view',async()=>{
|
||||
const p=deferred(),c=ui({playbackGeneration:2,queryCamera:'',resetPlayback(){},api:()=>p.promise,drawTimeline(){throw Error('late draw');}});
|
||||
c.$('#play-camera').value='camera';c.$('#play-date').value='2026-10-04';c.$('#archive-source').value='local';
|
||||
vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c);
|
||||
const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined);
|
||||
});
|
||||
test('download generation refuses gaps and invalidates old links first',()=>{
|
||||
const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error});
|
||||
vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c);
|
||||
c.$('#clip-start').value=date(0).toISOString();c.$('#clip-end').value=date(61).toISOString();c.makeClip();assert.equal(c.$('#clip-download').hidden,true);assert.match(c.$('#clip-status').textContent,/超出/);
|
||||
});
|
||||
test('preset load from a closed panel is ignored',async()=>{
|
||||
const p=deferred(),c=ui({ptzCamera:'first',ptzBusy:false,ptzPresetGeneration:0,clearPresetConfirmation(){},api:()=>p.promise,renderPresets(){throw Error('late render');}});
|
||||
vm.runInContext(section('monitor.js','async function loadPresets(','async function openPtz('),c);
|
||||
const work=c.loadPresets();c.ptzPresetGeneration++;c.ptzCamera='';p.resolve({items:[]});await work;
|
||||
});
|
||||
test('late goto result cannot overwrite a newer stop response',async()=>{
|
||||
const p=deferred(),c=ui({ptzBusy:false,ptzCamera:'camera',ptzPresets:{revision:'1'},ptzGeneration:1,ptzCommandGeneration:1,clearPresetConfirmation(){},setPtzBusy(){},api:()=>p.promise});
|
||||
vm.runInContext(section('monitor.js','async function presetCommand(','function closePtz('),c);
|
||||
const work=c.presetCommand('goto','1');c.ptzCommandGeneration++;c.$('#preset-status').textContent='stopped';p.resolve({message:'old goto'});await work;assert.equal(c.$('#preset-status').textContent,'stopped');
|
||||
});
|
||||
test('snapshot callback cannot reveal a prior login frame after logout',async()=>{
|
||||
let callback,created=0;const c=ui({sessionGeneration:1,snapshotGeneration:0,clearSnapshot(){c.snapshotGeneration++;},toast(){},URL:{createObjectURL(){created++;}},document:{createElement(){return{getContext:()=>({drawImage(){}}),toBlob:f=>callback=f};}}});
|
||||
vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c);
|
||||
const work=c.captureFrame({readyState:4,videoWidth:100,videoHeight:50},'camera','Test');c.sessionGeneration++;c.loggedIn=false;callback({});await work;assert.equal(created,0);
|
||||
});
|
||||
test('snapshot rejects empty or errored video without producing a blob',async()=>{
|
||||
let messages=0;const c=ui({toast:()=>messages++});vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c);
|
||||
await c.captureFrame({readyState:0,videoWidth:0});await c.captureFrame({readyState:4,videoWidth:100,error:{code:3}});assert.equal(messages,2);
|
||||
});
|
||||
+4
-19
@@ -15,6 +15,7 @@ function applyPermissions(){
|
||||
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
|
||||
}
|
||||
function clearPrivateView(){
|
||||
if(typeof clearSnapshot==='function')clearSnapshot();
|
||||
if(typeof clearCameraSettings==='function')clearCameraSettings();
|
||||
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
|
||||
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
|
||||
@@ -87,7 +88,7 @@ function renderLive() {
|
||||
const key = ZhaoLivePlayer.key(cameras, quality);
|
||||
if (key === liveKey) { updateLiveStatus(cameras); return; }
|
||||
stopPlayers(); liveKey = key;
|
||||
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><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-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
|
||||
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><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-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
|
||||
for (const c of cameras.filter(c=>c.enabled)) {
|
||||
const video = $(`video[data-camera="${c.id}"]`);
|
||||
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
|
||||
@@ -137,25 +138,9 @@ $('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearP
|
||||
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();};
|
||||
$('#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() {playbackGeneration++;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';}
|
||||
async function queryRecordings() {
|
||||
resetPlayback();const generation=playbackGeneration;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()}));if(generation!==playbackGeneration)return;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){if(generation===playbackGeneration)$('#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=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
|
||||
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};
|
||||
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);
|
||||
window.addEventListener('beforeunload',stopPlayers);
|
||||
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
|
||||
setInterval(refresh,12000);auth();
|
||||
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
|
||||
|
||||
function renderAssets(){
|
||||
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
|
||||
|
||||
+5
-3
@@ -6,6 +6,7 @@
|
||||
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
@@ -28,13 +29,13 @@
|
||||
<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="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></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>
|
||||
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></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>
|
||||
@@ -78,7 +79,8 @@
|
||||
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
|
||||
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
|
||||
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
|
||||
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
<dialog id="snapshot-dialog" aria-labelledby="snapshot-title"><div class="dialog-heading"><h2 id="snapshot-title">画面截图</h2><button data-close="snapshot-dialog" class="quiet">关闭</button></div><img id="snapshot-image" alt="当前视频画面截图"><p id="snapshot-info" class="description"></p><a id="snapshot-save" class="button" download>下载 PNG 图片</a></dialog>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+64
-14
@@ -70,23 +70,73 @@ document.addEventListener('click',e=>{
|
||||
if(b.dataset.wallBefore||b.dataset.wallAfter){const ids=wallOrder.filter(id=>filtered().some(c=>c.id===id)),id=b.dataset.wallBefore||b.dataset.wallAfter;wallMove(id,ids[ids.indexOf(id)+(b.dataset.wallBefore?-1:1)]);}
|
||||
if(b.dataset.ptzCamera)openPtz(b.dataset.ptzCamera);
|
||||
});
|
||||
let ptzPresets=null,ptzPresetGeneration=0,ptzBusy=false,ptzCommandGeneration=0,ptzReady=false,ptzConfirmation=null;
|
||||
function setPtzBusy(busy){
|
||||
ptzBusy=busy;
|
||||
$$('[data-ptz-action]').forEach(b=>b.disabled=!ptzReady||(busy&&b.dataset.ptzAction!=='stop'));
|
||||
$$('#ptz-presets button,#ptz-presets input').forEach(b=>b.disabled=busy);
|
||||
if(!busy&&ptzPresets)$('#preset-form button').disabled=ptzPresets.items.length>=ptzPresets.maximum;
|
||||
}
|
||||
function clearPresetConfirmation(){ptzConfirmation=null;$('#preset-confirmation').hidden=true;}
|
||||
function renderPresets(data){
|
||||
ptzPresets=data;clearPresetConfirmation();
|
||||
$('#preset-list').innerHTML=data.items.map(p=>`<div class="preset-row"><strong>${esc(p.name)}</strong><button class="quiet" data-preset-goto="${esc(p.token)}">转到</button>${isAdmin()?`<button class="quiet" data-preset-remove="${esc(p.token)}">删除</button>`:''}</div>`).join('')||'<p class="description">还没有预置位。把云台调到需要的位置后保存。</p>';
|
||||
$('#preset-count').textContent=`${data.items.length} / ${data.maximum} 个位置`;
|
||||
$('#preset-form').hidden=!isAdmin();$('#preset-form button').disabled=data.items.length>=data.maximum;
|
||||
}
|
||||
async function loadPresets(){
|
||||
if(!ptzCamera||ptzBusy)return;const id=ptzCamera,generation=++ptzPresetGeneration;
|
||||
ptzPresets=null;clearPresetConfirmation();$('#preset-list').replaceChildren();$('#preset-form').hidden=true;
|
||||
$('#preset-status').textContent='正在读取设备预置位…';$('#preset-reload').disabled=true;
|
||||
try{const data=await api('/api/ptz/presets?camera='+encodeURIComponent(id));if(generation!==ptzPresetGeneration||id!==ptzCamera||!loggedIn)return;renderPresets(data);$('#preset-status').textContent=data.note;}
|
||||
catch(e){if(!e.stale&&generation===ptzPresetGeneration)$('#preset-status').textContent=e.message;}
|
||||
finally{if(generation===ptzPresetGeneration)$('#preset-reload').disabled=false;}
|
||||
}
|
||||
async function openPtz(id){
|
||||
const generation=++ptzGeneration;ptzCamera=id;$('#ptz-panel').hidden=false;
|
||||
$('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台';
|
||||
$('#ptz-status').textContent='正在读取云台能力…';$$('[data-ptz-action]').forEach(b=>b.disabled=true);
|
||||
try{const caps=await api('/api/ptz?camera='+id);if(generation!==ptzGeneration)return;$('#ptz-status').textContent=caps.note;$$('[data-ptz-action]').forEach(b=>b.disabled=false);}
|
||||
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
|
||||
if(ptzCamera&&ptzCamera!==id)closePtz();
|
||||
const generation=++ptzGeneration;ptzCamera=id;ptzReady=false;$('#ptz-panel').hidden=false;
|
||||
$('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台与预置位';
|
||||
$('#ptz-status').textContent='正在读取云台能力…';setPtzBusy(false);loadPresets();
|
||||
try{const caps=await api('/api/ptz?camera='+id);if(generation!==ptzGeneration||!loggedIn)return;ptzReady=true;$('#ptz-status').textContent=caps.note;setPtzBusy(ptzBusy);}
|
||||
catch(e){if(!e.stale&&generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
|
||||
}
|
||||
async function ptzCommand(action){
|
||||
if(!ptzCamera)return;const id=ptzCamera,generation=ptzGeneration;
|
||||
$$('[data-ptz-action]:not([data-ptz-action="stop"])').forEach(b=>b.disabled=true);
|
||||
$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…';
|
||||
try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';}
|
||||
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
|
||||
finally{if(generation===ptzGeneration)$$('[data-ptz-action]').forEach(b=>b.disabled=false);}
|
||||
if(!ptzCamera||(!ptzReady&&action!=='stop')||(ptzBusy&&action!=='stop'))return;
|
||||
const id=ptzCamera,generation=ptzGeneration,command=++ptzCommandGeneration;
|
||||
setPtzBusy(true);$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…';
|
||||
try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration&&command===ptzCommandGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';}
|
||||
catch(e){if(!e.stale&&generation===ptzGeneration&&command===ptzCommandGeneration)$('#ptz-status').textContent=e.message;}
|
||||
finally{if(generation===ptzGeneration&&command===ptzCommandGeneration)setPtzBusy(false);}
|
||||
}
|
||||
function confirmPreset(action,token){
|
||||
if(!isAdmin()||ptzBusy||!ptzPresets)return;
|
||||
const preset=ptzPresets.items.find(p=>p.token===token);if(!preset)return;
|
||||
ptzConfirmation={action,token,name:preset.name};
|
||||
$('#preset-confirm-text').textContent=`删除“${preset.name}”?设备中的这个预置位会被移除。`;
|
||||
$('#preset-confirmation').hidden=false;
|
||||
}
|
||||
async function presetCommand(action,token,name){
|
||||
if(ptzBusy||!ptzCamera||!ptzPresets)return;
|
||||
const id=ptzCamera,generation=ptzGeneration,command=++ptzCommandGeneration,revision=ptzPresets.revision;
|
||||
clearPresetConfirmation();setPtzBusy(true);$('#preset-status').textContent=action==='goto'?'正在转到预置位,可随时点击停止…':'正在更新设备预置位…';
|
||||
try{const result=await api('/api/ptz/presets',{camera:id,action,token,name,revision});
|
||||
if(generation!==ptzGeneration||command!==ptzCommandGeneration||!loggedIn)return;
|
||||
if(result.presets){renderPresets(result.presets);$('#preset-form').reset();}
|
||||
$('#preset-status').textContent=result.message;
|
||||
}catch(e){if(!e.stale&&generation===ptzGeneration&&command===ptzCommandGeneration){ptzPresets=null;$('#preset-list').replaceChildren();$('#preset-form').hidden=true;$('#preset-status').textContent=e.message+'。请重新读取后核对。';}}
|
||||
finally{if(generation===ptzGeneration&&command===ptzCommandGeneration)setPtzBusy(false);}
|
||||
}
|
||||
function closePtz(){
|
||||
const id=ptzCamera;if(id&&ptzBusy&&loggedIn)api('/api/ptz',{camera:id,action:'stop'}).catch(()=>{});
|
||||
ptzGeneration++;ptzPresetGeneration++;ptzCommandGeneration++;ptzCamera='';ptzPresets=null;ptzBusy=false;ptzReady=false;
|
||||
$('#ptz-panel').hidden=true;$('#preset-list').replaceChildren();$('#preset-form').reset();clearPresetConfirmation();
|
||||
}
|
||||
function closePtz(){ptzGeneration++;ptzCamera='';$('#ptz-panel').hidden=true;}
|
||||
$('#ptz-close').onclick=closePtz;
|
||||
$$('[data-ptz-action]').forEach(b=>b.onclick=()=>ptzCommand(b.dataset.ptzAction));
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera)ptzCommand('stop');});
|
||||
window.addEventListener('pagehide',()=>{if(ptzCamera)fetch('/api/ptz',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:ptzCamera,action:'stop'})}).catch(()=>{});});
|
||||
$('#preset-reload').onclick=loadPresets;
|
||||
$('#preset-form').onsubmit=e=>{e.preventDefault();presetCommand('create',undefined,$('#preset-name').value);};
|
||||
$('#preset-confirm-cancel').onclick=clearPresetConfirmation;
|
||||
$('#preset-confirm-yes').onclick=()=>{if(ptzConfirmation){const p=ptzConfirmation;presetCommand(p.action,p.token,p.name);}};
|
||||
$('#preset-list').onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.presetGoto)presetCommand('goto',b.dataset.presetGoto);if(b.dataset.presetRemove)confirmPreset('remove',b.dataset.presetRemove);};
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera&&ptzBusy)ptzCommand('stop');});
|
||||
window.addEventListener('pagehide',()=>{if(ptzCamera&&ptzBusy)fetch('/api/ptz',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:ptzCamera,action:'stop'})}).catch(()=>{});});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
// Pure range logic shared by the UI and regression tests. All ranges are [start,end).
|
||||
const RecordingRanges=(()=>{
|
||||
function normalize(items,start,end){
|
||||
const lo=+start,hi=+end;if(!Number.isFinite(lo)||!Number.isFinite(hi)||hi<=lo)return [];
|
||||
const ranges=(Array.isArray(items)?items:[]).filter(s=>s&&Number.isFinite(+new Date(s.start))&&Number.isFinite(Number(s.duration))&&Number(s.duration)>0).map(s=>({start:Math.max(lo,+new Date(s.start)),end:Math.min(hi,+new Date(s.start)+Number(s.duration)*1000)}))
|
||||
.filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start);
|
||||
const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result;
|
||||
}
|
||||
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+3600000)}:null;}
|
||||
function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;}
|
||||
function clip(ranges,start,end){
|
||||
const a=+start,b=+end;
|
||||
if(!Number.isFinite(a)||!Number.isFinite(b)||b<=a)throw Error('结束时间必须晚于开始时间');
|
||||
if(b-a>3600000)throw Error('一次下载最多一小时,请缩短片段');
|
||||
if(!ranges.some(s=>a>=s.start&&b<=s.end))throw Error('所选片段跨越无录像时段或超出查询日期,请选择同一连续录像时段');
|
||||
return {start:a,end:b};
|
||||
}
|
||||
return {normalize,at,next,clip};
|
||||
})();
|
||||
if(typeof module!=='undefined')module.exports=RecordingRanges;
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
let playbackRange=null;
|
||||
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
|
||||
function resetPlayback(){
|
||||
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
|
||||
$('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset();
|
||||
spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML='';
|
||||
}
|
||||
async function queryRecordings(){
|
||||
resetPlayback();const generation=playbackGeneration,id=$('#play-camera').value,date=$('#play-date').value;
|
||||
if(!id||!date){toast('请选择摄像头和日期');return;}
|
||||
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()}));
|
||||
if(generation!==playbackGeneration||!loggedIn)return;
|
||||
spans=RecordingRanges.normalize(d.items,queryStart,queryEnd);
|
||||
$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();
|
||||
$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${new Date(s.start).toISOString()}">${new Date(s.start).toLocaleTimeString()} — ${new Date(s.end).toLocaleTimeString()}<small>${((s.end-s.start)/60000).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');
|
||||
if(spans.length)playAt(new Date(spans[0].start));
|
||||
}catch(e){if(!e.stale&&generation===playbackGeneration)$('#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=(s.start-queryStart)/total,b=(s.end-queryStart)/total;if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}
|
||||
}
|
||||
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}
|
||||
function playAt(date,preserveClip=false){
|
||||
const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;}
|
||||
playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
|
||||
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每次最多一小时,结束后按续播选项处理';$('#play-empty').hidden=true;
|
||||
$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;
|
||||
if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';}
|
||||
}
|
||||
function continuePlayback(){
|
||||
const v=$('#play-video');if(!loggedIn||activePage!=='playback'||!playbackRange||!v.ended)return;
|
||||
if(v.currentTime+2<(playbackRange.end-playbackRange.start)/1000){$('#play-info').textContent='录像提前结束,已停止续播;请核对该片段或重新查询';return;}
|
||||
if(!$('#play-autonext').checked){$('#play-info').textContent='本段播放结束,自动续播已关闭';return;}
|
||||
const end=playbackRange.end,next=RecordingRanges.next(spans,end);
|
||||
if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;}
|
||||
playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`;
|
||||
}
|
||||
function makeClip(){
|
||||
$('#clip-download').hidden=true;
|
||||
try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value));
|
||||
const a=$('#clip-download');a.href='/media/playback?'+playbackParams(range)+'&download=1';a.setAttribute('download','recording.mp4');a.hidden=false;$('#clip-status').textContent=`已选 ${((range.end-range.start)/60e3).toFixed(2)} 分钟,下载保留原始编码。`;
|
||||
}catch(e){$('#clip-status').textContent=e.message;}
|
||||
}
|
||||
$('#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 r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
|
||||
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
|
||||
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback;
|
||||
$('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();};
|
||||
$('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();};
|
||||
for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';};
|
||||
$$('[data-clip-mark]').forEach(b=>b.onclick=()=>{if(!playbackRange||!$('#play-video').videoWidth){toast('请先播放录像');return;}$('#clip-'+b.dataset.clipMark).value=localDateTime(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000));$('#clip-download').hidden=true;$('#clip-status').textContent='已使用当前播放位置,请重新生成链接';});
|
||||
$('#play-date').value=localDateTime(Date.now()).slice(0,10);window.addEventListener('resize',drawTimeline);
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
let snapshotUrl='',snapshotGeneration=0;
|
||||
function clearSnapshot(){snapshotGeneration++;if(snapshotUrl)URL.revokeObjectURL(snapshotUrl);snapshotUrl='';$('#snapshot-image').removeAttribute('src');$('#snapshot-save').removeAttribute('href');$('#snapshot-info').textContent='';}
|
||||
async function captureFrame(video,cameraId,label){
|
||||
if(!loggedIn||!video||video.readyState<2||!video.videoWidth||video.error){toast('画面尚未就绪,请播放后再截图');return;}
|
||||
clearSnapshot();const generation=snapshotGeneration,session=sessionGeneration,canvas=document.createElement('canvas');
|
||||
canvas.width=video.videoWidth;canvas.height=video.videoHeight;const captured=new Date();
|
||||
try{canvas.getContext('2d').drawImage(video,0,0);const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
|
||||
if(!loggedIn||session!==sessionGeneration||generation!==snapshotGeneration)return;
|
||||
if(!blob)throw Error('图片生成失败');snapshotUrl=URL.createObjectURL(blob);$('#snapshot-image').src=snapshotUrl;
|
||||
$('#snapshot-title').textContent=label+' · 画面截图';$('#snapshot-info').textContent=`${canvas.width} × ${canvas.height} · 本次截取时间 ${captured.toLocaleString()}。图片取自当前播放器画面,包含播放缓冲延迟;不上传到服务器。`;
|
||||
$('#snapshot-save').href=snapshotUrl;$('#snapshot-save').download='snapshot-'+cameraId+'-'+captured.toISOString().replace(/[:.]/g,'-')+'.png';$('#snapshot-dialog').showModal();
|
||||
}catch(e){if(generation===snapshotGeneration)toast('截图失败,请重新播放后再试');}
|
||||
}
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('[data-snapshot-camera]');if(b){const id=b.dataset.snapshotCamera;captureFrame(b.closest('.camera-card').querySelector('video'),id,state.cameras.find(c=>c.id===id)?.name||'摄像头');}});
|
||||
$('#play-snapshot').onclick=()=>captureFrame($('#play-video'),queryCamera,'录像回放');
|
||||
$('#snapshot-dialog').addEventListener('close',clearSnapshot);
|
||||
@@ -22,3 +22,7 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
|
||||
#page-live:fullscreen .camera-actions [data-camera-settings]{display:none}
|
||||
@media(max-width:700px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:420px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr}}
|
||||
|
||||
/* v0.1.21: presets and recording tools */
|
||||
.ptz-panel{width:620px;display:grid;grid-template-columns:1fr auto;max-height:80dvh;overflow:auto;align-items:start}.ptz-panel>div:first-child{grid-column:1}.ptz-panel .ptz-pad{grid-column:2;grid-row:1/3}#ptz-presets{grid-column:1/-1;min-width:0;border-top:1px solid var(--line);padding-top:12px}#ptz-presets .section-head{margin:0;gap:10px}#ptz-presets p{max-width:none;line-height:1.6}#preset-count{color:var(--muted);font-size:12px}.preset-row{display:flex;align-items:center;gap:5px;padding:9px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}.preset-row strong{font-size:13px;flex:1;overflow-wrap:anywhere}.preset-row button{padding:5px 8px;font-size:12px}#preset-form{display:flex;align-items:end;gap:10px;margin-top:12px}#preset-form label{flex:1;margin:0}#preset-name{width:100%}#preset-confirmation{padding:12px;background:#553b31;border-radius:8px;margin-top:10px}#ptz-close{justify-self:start}.playback-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:12px 0}.playback-tools .check{margin:0}.clip-editor{border-top:1px solid var(--line);padding-top:12px}.clip-editor summary{cursor:pointer;margin-bottom:14px}#snapshot-dialog{width:min(1000px,92vw)}#snapshot-image{width:100%;max-height:65vh;object-fit:contain;background:#000;border-radius:8px}#play-position{font-size:13px;color:var(--muted)}
|
||||
@media(max-width:600px){.ptz-panel{right:12px;bottom:12px;padding:12px;gap:12px}.ptz-panel .ptz-pad{grid-column:2}#preset-form{flex-wrap:wrap}#preset-form label{flex-basis:100%}}
|
||||
|
||||
Reference in New Issue
Block a user