diff --git a/README.md b/README.md index 32daae7..79881fd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ python3 app.py mediamtx ./data/mediamtx.yml ``` -回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 +回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。 @@ -167,3 +167,15 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有 ### 实机验收完善(v0.1.20) 摄像机信息及参数改为紧凑布局;全屏监控隐藏管理参数入口。读取或保存时暂时锁定参数输入,避免正在编辑的值被返回结果覆盖。视频服务错误响应也会正确关闭,修复新 Python 版本提示的资源未释放问题。 + + +## 云台常用位置、截图与录像续播(v0.1.21) + +- “云台控制”中读取设备预置位。管理员可以保存当前位置、删除位置;有对应空间与云台权限的个人账号可以查看和转到已有位置。保存和删除前检查版本,保存后回读设备列表。位置保存在摄像机中,双镜头可能共用同一云台。名称最多 40 字。本版不提供覆盖已有位置:实测 H9c 当前固件拒绝标准更新请求,调整后可另存新位置,再由管理员决定是否删除旧位置。 +- 云台操作按物理设备 / 节点互斥。预置位转动后查询设备状态,观察窗口为 10 秒,单次设备请求另有 5 秒超时;结束、超时或异常都尝试发送 Stop。停止按钮不排队,可取消正在等待的转动。设备网络失联时无法保证 Stop 送达,界面会明确提示核对画面。关闭面板或切换后台时,对正在执行的操作尝试停止。预置位位置和设备能力仍以具体型号、固件实际返回为准。 +- 实时画面与录像回放增加“截图”。截取当前播放器已解码的一帧,可预览并下载 PNG。图片仅在当前浏览器内存中生成,不上传到服务器,关闭后释放。截图分辨率取决于当前预览码流;截取时间不是摄像机源时间,仍有播放缓冲延迟。 +- 本机录像查询按所选日期裁剪、排序与合并重叠范围。每次转换最多一小时,播放结束可以继续下一段;真实空档会跳过并提示。可取消自动续播。未正常播完的片段不会自动略过,主动暂停不会触发续播。 +- 录像页显示实际录像时间,支持以当前播放位置标记片段起止、手动修改起止时间、生成原始编码下载链接。片段最长一小时,不能跨越无录像空档;边界受原始关键帧影响,非逐帧无损剪辑。自动续播保留正在填写的自定义片段。 +- 本版不创建新的家庭数据或修改已有账户和录像策略;新增界面及设备操作继续使用既有空间、管理与云台权限。录像机原有历史录像仍未适配,以上续播与片段工具用于小电脑本机录像。 + +预置位协议依据:[ONVIF PTZ Service 5.4](https://www.onvif.org/specs/srv/ptz/ONVIF-PTZ-Service-Spec.pdf)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。 diff --git a/VERSION b/VERSION index baa9837..7906299 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.20 +0.1.21 diff --git a/app.py b/app.py index 63279f8..08ffd9b 100644 --- a/app.py +++ b/app.py @@ -597,7 +597,7 @@ class Handler(BaseHTTPRequestHandler): return self.require_auth() if path not in ('/api/logout', '/api/account/password', '/api/family/restore', - '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'): + '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'): self.require_admin() if path == '/api/logout': with LOCK: @@ -619,6 +619,18 @@ class Handler(BaseHTTPRequestHandler): raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502) audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username']) self.answer(result) + elif path == '/api/ptz/presets': + c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera'))) + if data.get('action') != 'goto': + self.require_admin() + try: + result = ptz.CONTROLLER.preset_command(c, data) + except ValueError as e: + raise Problem(str(e), 400) + except Exception: + raise Problem('设备操作未确认,请核对画面并重新读取预置位,勿重复提交;转动异常可点击停止', 502) + audit('云台预置位 ' + data['action'], c['name'] + ' / ' + self.user['username']) + self.answer(result) elif path == '/api/ptz': c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera'))) try: @@ -778,6 +790,15 @@ class Handler(BaseHTTPRequestHandler): elif path == '/api/users': self.require_admin() self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]}) + elif path == '/api/ptz/presets': + c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera'))) + try: + result, _ = ptz.CONTROLLER.read_presets(c) + except ValueError as e: + raise Problem(str(e), 400) + except Exception: + raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502) + self.answer(dict(result, canManage=self.user['role'] == 'admin')) elif path == '/api/ptz': c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera'))) try: @@ -858,6 +879,7 @@ class Handler(BaseHTTPRequestHandler): '/family-events.js': 'family-events.js', '/family-history.js': 'family-history.js', '/camera-settings.js': 'camera-settings.js', + '/recording-ranges.js': 'recording-ranges.js', '/recording-ui.js': 'recording-ui.js', '/snapshot.js': 'snapshot.js', '/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js', '/zhao-icon.png': 'zhao-icon.png', '/vendor/hls.min.js': 'vendor/hls.min.js'} @@ -873,7 +895,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('X-Frame-Options', 'DENY') self.send_header('Referrer-Policy', 'same-origin') - self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") self.send_header('Cache-Control', 'no-store' if path == '/' else 'no-cache') self.end_headers() self.wfile.write(raw) diff --git a/onvif.py b/onvif.py index d147db0..e064e21 100644 --- a/onvif.py +++ b/onvif.py @@ -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 diff --git a/ptz.py b/ptz.py index 76a6ad3..4b2d21f 100644 --- a/ptz.py +++ b/ptz.py @@ -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', + '' + escape(info['node']) + '') + maximum = int(node.findtext('.//' + T + 'MaximumNumberOfPresets', '0')) + if not 0 < maximum <= 1024: + raise ValueError('设备未开放预置位功能') + root = self.call(c, info['endpoint'], PTZ, 'GetPresets', + '' + escape(info['profile']) + '') + 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 = '' + escape(info['profile']) + '' + 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 + '' + escape(token) + '') + 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 + 'truetrue') + 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 + '' + escape(name) + '' + 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 + '' + escape(token) + '') + 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 + 'true') 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. diff --git a/test_app.py b/test_app.py index 34a2ee1..bf02601 100644 --- a/test_app.py +++ b/test_app.py @@ -59,6 +59,33 @@ class WebTests(unittest.TestCase): with self.subTest(path=path): self.assertEqual(self.request(path, False)[0], 401) + def test_presets_api_enforces_control_and_management_roles(self): + camera = dict(id='f'*16, name='Preset test', siteId='preset-site', enabled=True) + app.save_object('sites', dict(id='preset-site', name='Preset site')) + app.save_object('cameras', camera) + viewer = app.ACCOUNTS.save(dict(username='preset-viewer', password='test-password', siteIds=['preset-site'], ptzControl=True), self.admin) + token = '2'*64 + with app.LOCK: + app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, viewer['id'])) + app.DB.commit() + path='/api/ptz/presets' + payload=dict(camera=camera['id'],action='goto',token='1',revision='v1') + with patch.object(app.ptz.CONTROLLER,'read_presets',return_value=({'items':[]},{})),patch.object(app.ptz.CONTROLLER,'preset_command',return_value={'ok':True}) as command: + self.assertEqual(self.request(path+'?camera='+camera['id'],False)[0],401) + code,_,body=self.request(path+'?camera='+camera['id'],token=token) + self.assertEqual(code,200);self.assertFalse(json.loads(body)['canManage']) + self.assertEqual(self.request(path,data=payload,token=token)[0],200) + for action in ['create','replace','remove']: + self.assertEqual(self.request(path,data=dict(payload,action=action),token=token)[0],403) + self.assertEqual(command.call_count,1) + self.assertEqual(self.request(path,data=dict(payload,action='create'))[0],200) + self.assertEqual(self.request(path,data=payload,origin='https://other.example')[0],403) + app.ACCOUNTS.save(dict(viewer,ptzControl=False),self.admin) + with app.LOCK: + app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, viewer['id'])) + app.DB.commit() + self.assertEqual(self.request(path+'?camera='+camera['id'],token=token)[0],403) + def test_static_security_headers(self): code, headers, _ = self.request('/', False) self.assertEqual(code, 200) @@ -78,7 +105,7 @@ class WebTests(unittest.TestCase): self.assertEqual(headers['Cache-Control'], 'no-store') self.assertNotIn(b'__ASSET_VERSION__', body) assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) - self.assertEqual(len(assets), 15) + self.assertEqual(len(assets), 18) version = (app.ROOT / 'VERSION').read_text().strip() for asset in assets: self.assertTrue(asset.endswith('?v=' + version)) diff --git a/test_ptz_presets.py b/test_ptz_presets.py new file mode 100644 index 0000000..e1b8fc4 --- /dev/null +++ b/test_ptz_presets.py @@ -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('' + body + '') + + +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('12') + items = xml('Door') + 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('0'), xml('999999')]: + 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('')]): + 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('2')) + self.assertTrue(result['verified']) + self.assertIn('Yard & Gate', call.call_args.args[4]) + self.assertNotIn('', 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('2')) + 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('IDLE') + 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('MOVING')) 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() diff --git a/test_recording_tools.js b/test_recording_tools.js new file mode 100644 index 0000000..6ef40cb --- /dev/null +++ b/test_recording_tools.js @@ -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); +}); diff --git a/web/app.js b/web/app.js index c2009d8..0026f97 100644 --- a/web/app.js +++ b/web/app.js @@ -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=>`
⠿ 拖动
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); + $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
⠿ 拖动
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):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=>``).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); diff --git a/web/index.html b/web/index.html index 132c305..6c16073 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,7 @@ +