Compare commits

..

2 Commits

Author SHA1 Message Date
Codex a3f4544ddf feat: introduce Chinese courtyard visual theme v0.1.22 2026-10-04 08:59:15 +08:00
Codex 68be32e269 Add camera presets snapshots and continuous recording playback 2026-10-04 08:33:00 +08:00
17 changed files with 838 additions and 58 deletions
+17 -1
View File
@@ -2,6 +2,10 @@
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
## 界面风格
v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点缀与淡金本人标记。统一实时监控、家谱树、家族记事、设备档案、表单和登录界面;全屏监控保留深色背景。样式集中在 `web/chinese-theme.css`,本地系统宋体用于标题,不请求外部字体。桌面和手机使用同一套功能与自适应布局。
## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
@@ -49,7 +53,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 test_theme.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 +171,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)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。
+1 -1
View File
@@ -1 +1 @@
0.1.20
0.1.22
+25 -3
View File
@@ -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:
@@ -854,10 +875,11 @@ class Handler(BaseHTTPRequestHandler):
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/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)
+7 -2
View File
@@ -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
+100
View File
@@ -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
View File
@@ -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), 19)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
+104
View File
@@ -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 &amp; 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()
+60
View File
@@ -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);
});
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, 'web/chinese-theme.css'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, 'web/index.html'), 'utf8');
const root = css.match(/:root\s*\{([^}]+)\}/)[1];
const token = name => root.match(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})'))[1];
function luminance(hex) {
const values = hex.slice(1).match(/../g).map(v => parseInt(v, 16) / 255)
.map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4);
return values[0] * .2126 + values[1] * .7152 + values[2] * .0722;
}
function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
}
test('paper theme is loaded last, versioned and uses only local fonts', () => {
const styles = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map(x => x[1]);
assert.equal(styles.at(-1), '/chinese-theme.css?v=__ASSET_VERSION__');
assert.doesNotMatch(css, /@import|https?:|@font-face/i);
assert.match(html, /name="theme-color" content="#213d34"/);
assert.match(html, /class="courtyard-landscape"[^>]+aria-hidden="true"/);
});
test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
for (const [fg, bg] of [
[token('ink'), token('paper')], [token('muted'), token('paper')],
[token('muted'), token('panel')], ['#fffdf6', token('accent')],
['#c9d1be', '#213d34'], ['#244638', '#efebdc'],
['#355b3d', '#e4eddf'], ['#835428', '#f2e8d9'],
['#994437', '#fbf8ef'], ['#d9dfca', '#24372b'],
]) assert.ok(contrast(fg, bg) >= 4.5, `${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`);
});
test('monitor fullscreen, reduced motion and mobile navigation are explicitly styled', () => {
assert.match(css, /#page-live:fullscreen\s*\{[^}]+color-scheme: dark/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(css, /overflow-x: auto/);
assert.match(css, /\.family-node\.self\s*\{[^}]+background: #f1ddb0/);
assert.match(css, /--timeline-paper:/);
});
test('recording timeline paints with theme tokens and keeps range geometry', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, 'web/recording-ui.js'), 'utf8');
const draw = source.slice(source.indexOf('function drawTimeline()'), source.indexOf('function playbackParams('));
const rectangles = [];
const ctx = { scale() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
fillRect(...coordinates) { rectangles.push({ color: this.fillStyle, coordinates }); } };
const canvas = { clientWidth: 480, getContext: () => ctx };
const values = { '--timeline-paper': '#ede8dd', '--timeline-grid': '#d3ccbc', '--timeline-range': '#4c7761' };
const scope = vm.createContext({ $: () => canvas, window: { devicePixelRatio: 2 }, document: { documentElement: {} },
getComputedStyle: () => ({ getPropertyValue: name => values[name] }),
queryStart: new Date(0), queryEnd: new Date(60000), spans: [{ start: 10000, end: 20000 }] });
vm.runInContext(draw + ';drawTimeline()', scope);
assert.equal(canvas.width, 960);
assert.equal(canvas.height, 152);
assert.deepEqual(rectangles, [
{ color: '#ede8dd', coordinates: [0, 0, 480, 76] },
{ color: '#4c7761', coordinates: [80, 25, 80, 25] },
]);
assert.equal(ctx.strokeStyle, '#d3ccbc');
});
+4 -19
View File
@@ -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);
+245
View File
@@ -0,0 +1,245 @@
/* 赵府智家 · 庭院书卷。Keep visual tokens separate from layout and playback. */
:root {
color-scheme: light;
--paper: #f4f0e7;
--ink: #2d3932;
--muted: #6e6b5f;
--line: #dcd5c6;
--panel: #fffdf8;
--accent: #315b4d;
--cinnabar: #9e4b3d;
--gold: #86682f;
--serif: "Songti SC", "STSong", "SimSun", serif;
--timeline-paper: #ede8dd;
--timeline-grid: #d3ccbc;
--timeline-range: #4c7761;
color: var(--ink);
background: var(--paper);
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
}
body {
background-image: radial-gradient(ellipse at 85% 0, #e4e9df80, transparent 50%);
min-width: 0;
}
::selection { background: #e8d7ad; color: #2d3932; }
button, .button, input, select, textarea, fieldset { border-radius: 5px; }
button, .button { background: var(--accent); color: #fffdf6; font-weight: 500; transition: background .15s, border-color .15s, box-shadow .15s; }
button:hover, .button:hover { filter: brightness(.95); }
.secondary { background: #e8ede5; color: #315b4d; border-color: #c7d3c4; }
.quiet { background: transparent; color: #586653; border-color: #cfcfbf; }
input, select, textarea { color: var(--ink); background: #fffefa; border-color: #cbc5b6; }
input::placeholder, textarea::placeholder { color: #847f72; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #315b4d12; }
input[type=range], input[type=checkbox] { accent-color: var(--accent); }
label, .family-date legend { color: #625f53; }
h1, h2, h3 { font-family: var(--serif); font-weight: 600; }
h1 { font-size: 34px; letter-spacing: 4px; margin-top: 17px; }
h2 { letter-spacing: 1px; }
.sidebar {
width: 222px;
background: #213d34;
background-image: linear-gradient(145deg, #2d4b3d, transparent 60%);
border-right: 1px solid #425344;
padding: 30px 18px 20px;
color: #ebe7d8;
scrollbar-width: thin;
scrollbar-color: #6f8271 #213d34;
}
.sidebar .brand { gap: 11px; padding: 4px 5px 30px; color: #f3eddf; font-family: var(--serif); font-size: 22px; letter-spacing: 2px; }
.brand-icon { width: 44px; height: 44px; border-radius: 5px; outline: 1px solid #c6b98c66; outline-offset: 3px; }
.brand small { font-family: "Microsoft YaHei", sans-serif; color: #c0c7b5; font-size: 9px; letter-spacing: 2px; margin-top: 10px; }
.nav-label { color: #b2bba6; font-size: 10px; padding: 12px 13px 17px; letter-spacing: 3px; border-top: 1px solid #b9c2aa24; }
.sidebar nav { gap: 5px; }
.sidebar nav button { color: #c9d1be; border-radius: 4px; padding: 12px 13px; gap: 13px; position: relative; border: 1px solid transparent; background: transparent; }
.sidebar nav button span { font-size: 13px; letter-spacing: .6px; }
.sidebar nav button:hover { background: #ffffff09; filter: none; }
.sidebar nav button.active { background: #efebdc; border-color: #e0d6b8; color: #244638; }
.sidebar nav button.active::after { content: ""; width: 5px; height: 5px; background: var(--cinnabar); position: absolute; right: 13px; transform: rotate(45deg); }
.sidebar .side-note { padding: 22px 5px 8px; color: #c4ccb9; font-size: 11px; }
.sidebar .dot { background: #afc39c; width: 5px; height: 5px; }
.sidebar #current-user { color: #d3d7c8; padding: 10px 5px 0; border-top: 1px solid #b9c2aa24; }
.sidebar .quiet { color: #d1d6c6; border-color: #8b9a813f; padding: 9px; font-size: 11px; }
.sidebar #account-password { margin-bottom: 8px; }
.sidebar :focus-visible { outline-color: #e7c887; }
main { margin-left: 222px; padding: 30px 38px 22px; max-width: 1900px; }
main > header { min-height: 142px; padding: 13px 0 23px; margin-bottom: 25px; border-bottom: 1px solid #b4b5a2; position: relative; gap: 24px; isolation: isolate; }
main > header::after { content: ""; position: absolute; bottom: -2px; left: 0; height: 3px; width: 48px; background: var(--cinnabar); }
main > header > div { position: relative; z-index: 1; }
.eyebrow { color: var(--gold); font-size: 10px; letter-spacing: 3px; }
header p { color: #686f60; font-size: 12px; letter-spacing: .5px; }
.header-status { color: #666e5e; gap: 12px; font-size: 11px; flex-shrink: 0; }
.courtyard-landscape { position: absolute; width: 330px; height: 125px; right: 5px; bottom: 0; z-index: 0; pointer-events: none; opacity: .55; }
.badge { background: #e4eddf; color: #355b3d; border: 1px solid #c8d6be; border-radius: 3px; font-size: 10px; }
.badge.off { background: #f2e8d9; color: #835428; border-color: #dfc6a3; }
.metrics { background: #fcfaf4; border-radius: 6px; box-shadow: 0 3px 12px #3a402905; }
.metrics > div { padding: 20px 22px; align-items: baseline; }
.metrics span { color: #6f7260; }
.metrics strong { font-family: Georgia, var(--serif); color: #365846; font-size: 29px; }
.filter-row { color: #6e6b5f; margin: 24px 0; }
.section-head { margin-top: 25px; }
.section-head small { color: #8a7859; font-size: 9px; letter-spacing: 2px; }
.wall-toolbar { padding: 12px 15px; background: #eae8dc; border: 1px solid var(--line); border-radius: 5px; gap: 14px; }
.wall-toolbar select { background: #f9f6ed; }
.live-grid { gap: 22px; }
.camera-card, .site-card, .panel, .empty, .family-canvas-panel { border-radius: 6px; box-shadow: 0 4px 18px #39422a05; }
.camera-card { border-color: #d2cebe; }
.camera-meta { padding: 17px 18px 13px; }
.camera-meta strong { font-family: var(--serif); font-size: 16px; letter-spacing: .5px; }
.camera-meta p { color: #737464; margin-top: 7px; }
.camera-actions { flex-wrap: wrap; gap: 7px; padding-bottom: 16px; }
.camera-actions .quiet { border-color: #dbd9cb; color: #52604d; background: #faf9f2; }
.video-panel { border-radius: 5px; border-color: #c1c6b8; }
.video-empty { color: #b0bdae; background: radial-gradient(ellipse at center, #243d34 0, transparent 70%); }
.video-empty .lens { color: #a5bca4; }
.video-error { color: #f6d7c1; background: #472c26ed; }
.empty { color: #6c7462; border-color: #c3c6b6; }
.empty h3 { color: #384e3b; font-size: 21px; }
.panel { padding: 24px; }
.time-labels { color: #6e705e; }
.recording-item { color: #35533f; background: #f9f7ee; border-color: #d5d1c0; }
.recording-item small { color: #737261; }
.scan-chip { background: #fffdf7; border-radius: 5px; }
.device-table { border-radius: 5px; }
.device-table th { color: #6a6d5c; background: #ecebde; }
.device-table td small { color: #737261; }
.device-table tbody tr:hover { background: #f2f1e7; }
.site-card h3 { font-size: 20px; }
.site-card dt { color: #737261; }
.site-card dd { color: #384e3b; }
.audit-item { color: #56654f; }
.audit-item small { color: #767461; }
.banner { color: #784c26; background: #f6e9cf; border-color: #dfc797; }
dialog { background: #fbf8ef; color: var(--ink); border: 1px solid #d5c8ac; border-radius: 8px; box-shadow: 0 28px 100px #172c3455; padding: 30px; }
dialog::backdrop { background: #20382cb8; backdrop-filter: blur(5px); }
.dialog-heading { border-bottom: 1px solid var(--line); padding-bottom: 20px; gap: 16px; }
.dialog-heading h2 { font-size: 23px; }
.dialog-footer { gap: 10px; flex-wrap: wrap; }
#auth-dialog { border-top: 4px solid var(--cinnabar); padding: 38px; background-image: radial-gradient(ellipse at 100% 0, #e4e9dd, transparent 65%); }
#auth-dialog .brand-icon { width: 52px; height: 52px; }
#auth-form h2 { font-size: 32px; letter-spacing: 3px; margin-top: 26px; }
#auth-form p { color: #6b725d; }
.form-error { color: #994437; }
footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
#toast { background: #2a4c3c; color: #f3efdc; border-color: #819672; border-radius: 5px; box-shadow: 0 8px 25px #30433726; max-width: calc(100vw - 30px); }
.ptz-panel { background: #f8f5eb; border-color: #b9c5ad; border-radius: 7px; box-shadow: 0 12px 50px #26392d44; color: var(--ink); }
.ptz-pad [data-ptz-action=stop] { color: #8b4236; background: #efdbd0; border-color: #d5b2a4; }
#preset-confirmation { background: #f4e2d5; }
#snapshot-image { border-radius: 4px; }
/* Family register: paper, ink lines and a restrained gold highlight for oneself. */
.family-canvas-panel { background: #fcfaf3; border-color: #c7cbb7; }
.family-map-heading { padding-top: 24px; }
.family-map-heading h2 { letter-spacing: 3px; font-size: 25px; }
.family-view-tabs { background: #f1eee2; border-color: #d7d0bf; border-radius: 4px; }
.family-view-tabs button { color: #606650; border-radius: 2px; }
.family-view-tabs [aria-pressed=true] { background: #365945; color: #fffbed; }
.family-map-viewport { background-color: #efece0; background-image: radial-gradient(#c9cbbb 1px, transparent 1px); scrollbar-color: #8b9a7b #e8e6d8; }
.family-map-lines marker path { fill: #678b71; }
.family-edge { stroke: #94a58a; }
.family-edge.focused { stroke: #446b50; }
.family-edge.spouse { stroke: #a9854f; }
.family-edge.nonbiological { stroke: #93829e; }
.family-node { background: #fffdf6; border-color: #c9cdb9; border-radius: 5px; color: #304632; box-shadow: 0 3px 12px #3943250d; }
.family-node > strong { font-family: var(--serif); font-size: 17px; }
.family-node-avatar { background: #e8ecdf; color: #536e45; border-radius: 4px; }
.family-node .family-node-relation { color: #53664a; }
.family-node > small, .family-node-flags > small { color: #6f775f; }
.family-node.self { background: #f1ddb0; border-color: #af873f; box-shadow: 0 5px 18px #b4872425; }
.family-node.self .family-node-avatar { background: #f8eac9; }
.family-node.selected { outline-color: #426c50; }
.family-node.search-hit::after { background: #596b78; }
.self-badge { background: #94682f; color: #fff8e9; border-radius: 2px; }
.family-map-footer { border-color: #d0d3c0; }
.family-map-legend { color: #69705b; }
.family-map-footer #family-map-note:not(:empty) { color: #87652f; }
.family-inspector.is-self { background: linear-gradient(135deg, #f3e3bd, #fcfaf1 65%); border-color: #c3a66b; }
.family-self-heading { color: #88652d; letter-spacing: 1px; }
.family-register { background: #eeecdf; }
.person-card { color: #344732; border-color: #c9cbb9; }
.person-card.selected { background: #e6ecdc; outline-color: #66845b; }
.person-card.self { background: #f1ddb0; border-color: #b89553; color: #5f461f; }
.person-card.self span:not(.self-badge), .person-card.self small { color: #765b2e; }
.family-tree ul { border-color: #a2b197; }
.family-workspace:is(:fullscreen, .family-expanded) { background: var(--paper); }
.family-event-filters { background: #f0eee2; }
.timeline-year { color: #456344; font-family: var(--serif); }
.timeline-event::before { border-color: #b7c2a5; }
.timeline-event::after { background: #768d5f; box-shadow: 0 0 0 4px #e7ebdc; }
.timeline-event > time { color: #6c745d; }
.timeline-event-body { background: #fffdf6; border-color: #d3d6c2; border-radius: 5px; }
.timeline-event:hover .timeline-event-body { background: #f7f7ec; border-color: #8caa78; }
.timeline-event-body > strong { color: #344d32; font-family: var(--serif); font-size: 18px; }
.timeline-event-status, .timeline-summary, .timeline-event-body > small { color: #6b725b; }
.timeline-read { color: #41623b; }
.timeline-event.draft .timeline-event-body { background: #efede3; }
.timeline-event.draft::after { background: #a28a62; }
.event-detail-meta, .person-event-link time { color: #71745e; }
.event-lead { background: #eaecdd; border-color: #8e9f77; color: #46553b; }
.event-article { color: #424c3a; }
.person-event-link { color: #455a3c; }
.history-columns dl > div { border-color: #d9d6c4; }
.history-changed { background: #e8ecda; border-radius: 4px; }
#history-confirm { border-color: #b3be98; border-radius: 5px; background: #efefdf; }
/* The monitoring wall stays dark to keep attention on live video. */
#page-live:fullscreen { --muted: #c0c9b4; --line: #52614d; --panel: #25352c; color: #efecdd; background: #111c16; color-scheme: dark; }
#page-live:fullscreen .wall-toolbar { background: #24372b; border-color: #40543e; }
#page-live:fullscreen label { color: #d9deca; }
#page-live:fullscreen select { background: #1a2a20; color: #ebeede; border-color: #65765a; }
#page-live:fullscreen .quiet { color: #d9dfca; border-color: #66765a; background: transparent; }
#page-live:fullscreen .camera-card { border-color: #394f38; }
#page-live:fullscreen .camera-actions { background: #17251ee8; }
#page-live:fullscreen .ptz-panel { --muted: #6e6b5f; --line: #dcd5c6; color-scheme: light; }
#page-live:fullscreen .ptz-panel label, #page-live:fullscreen .ptz-panel .quiet { color: #586653; border-color: #cfcfbf; }
@media (min-width: 1600px) { main { padding-left: 46px; padding-right: 46px; } }
@media (max-width: 1100px) {
.sidebar { width: 200px; padding: 27px 14px 18px; }
.sidebar .brand { font-size: 20px; gap: 9px; }
.brand-icon { width: 38px; height: 38px; }
main { margin-left: 200px; padding: 24px; }
.metrics > div { padding: 18px 14px; }
.metrics strong { font-size: 25px; }
.courtyard-landscape { width: 260px; opacity: .4; }
}
@media (max-width: 760px) {
.sidebar { position: static; width: auto; padding: 22px 17px 14px; display: grid; grid-template-columns: 1fr auto; gap: 0 10px; overflow: visible; }
.sidebar .brand { padding: 2px 4px 20px; font-size: 22px; grid-column: 1/-1; }
.brand-icon { width: 38px; height: 38px; }
.brand small { font-size: 9px; }
.sidebar nav { grid-column: 1/-1; display: flex; overflow-x: auto; gap: 5px; padding-bottom: 12px; scrollbar-width: thin; scrollbar-color: #819573 #213d34; }
.sidebar nav button { padding: 10px 12px; gap: 7px; flex-shrink: 0; }
.sidebar nav button span { font-size: 12px; }
.sidebar nav button.active::after { display: none; }
.sidebar #current-user { position: static; grid-column: 1/-1; margin: 4px 0 10px; padding-top: 12px; font-size: 11px; }
.sidebar #account-password, .sidebar #logout { margin: 0; font-size: 11px; padding: 8px 12px; align-self: center; width: fit-content; }
.sidebar #logout { justify-self: end; }
main { margin: 0; padding: 20px 17px; }
main > header { min-height: 132px; padding-top: 9px; gap: 10px; margin-bottom: 20px; }
h1 { font-size: 28px; letter-spacing: 3px; }
.eyebrow { font-size: 9px; letter-spacing: 2px; }
.header-status { font-size: 9px; gap: 9px; max-width: 123px; padding-top: 2px; }
.header-status .badge { font-size: 9px; }
header p { max-width: 205px; font-size: 11px; line-height: 1.8; }
.courtyard-landscape { width: 170px; height: 90px; right: 0; opacity: .38; }
.metrics > div { padding: 16px; }
.metrics strong { font-size: 25px; }
.metrics > div:nth-last-child(-n+2) { border-bottom: 0; }
.filter-row { margin: 20px 0; }
.filter-row select { max-width: 155px; }
.filter-row > button { padding: 9px; }
.section-head { flex-wrap: wrap; }
.section-head > div { gap: 10px; }
.wall-toolbar { padding: 11px; gap: 10px; }
.wall-toolbar label { font-size: 12px; }
#wall-fullscreen { padding: 10px 12px; }
.camera-meta { padding: 14px; }
.camera-meta strong { font-size: 15px; }
.camera-actions { padding: 0 14px 14px; gap: 8px; }
.camera-actions button { padding: 9px 11px; }
.panel, .site-card { padding: 18px; }
.family-map-heading h2 { font-size: 23px; }
dialog { padding: 22px; }
#auth-dialog { padding: 30px 26px; }
.dialog-heading h2 { font-size: 21px; }
.dialog-footer { gap: 8px; }
footer { gap: 16px; flex-wrap: wrap; font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
+19 -17
View File
@@ -2,15 +2,16 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<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">
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>HOME / 本地家庭空间</small></span></a>
<div class="nav-label">工作空间</div>
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>家和 · 岁岁安宁</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>
@@ -24,31 +25,31 @@
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</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>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</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="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>守望</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="section-head"><h2>回到那一刻 <small>光阴</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>
<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">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</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>器物</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>存影</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>家宅</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>留存</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">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>FAMILY REGISTER</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<div id="family-error" class="form-error" role="status"></div>
<div id="family-workspace" class="family-workspace">
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
<div class="family-map-heading"><div><span class="eyebrow">FAMILY CONNECTIONS</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div class="family-map-heading"><div><span class="eyebrow">枝叶相连 · 世代相承</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
<div id="family-people" class="family-people family-register" hidden></div>
@@ -65,7 +66,7 @@
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。</p><div id="user-list"></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="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</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="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" 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="archiveRecorderId"><option value="">尚未绑定</option></select></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><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><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>
@@ -75,10 +76,11 @@
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<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="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">家事 · 家声</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
View File
@@ -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(()=>{});});
+21
View File
@@ -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;
+61
View File
@@ -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);const theme=getComputedStyle(document.documentElement);ctx.fillStyle=theme.getPropertyValue('--timeline-paper').trim()||'#ede8dd';ctx.fillRect(0,0,w,76);ctx.strokeStyle=theme.getPropertyValue('--timeline-grid').trim()||'#d3ccbc';
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=theme.getPropertyValue('--timeline-range').trim()||'#4c7761';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);
+17
View File
@@ -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);
+4
View File
@@ -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%}}