diff --git a/README.md b/README.md index b07aefb..fb27a90 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点 **录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。 -本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。 +本机录像默认关闭,用户逐通道开启。默认最长保留 30 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。保留天数是时间上限,不保证在容量限制内存满该天数。文件路径及录像索引使用 UTC,界面按浏览器时区显示。 HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 diff --git a/RELEASE-v0.1.38.md b/RELEASE-v0.1.38.md new file mode 100644 index 0000000..d80aac1 --- /dev/null +++ b/RELEASE-v0.1.38.md @@ -0,0 +1,13 @@ +# v0.1.38 — 录像留存与回放修复 + +- 新安装默认最长保留 30 天,保留现有 40 GB 容量上限、8 GB 磁盘预留;既有安装的自定义策略不会因升级被覆盖。 +- 容量不足时暂停写入,空间恢复后继续;过期录像由 MediaMTX 自动清理。保留天数是上限,实际时长取决于码率、路数与可用容量。 +- 回放页显示实际写入、未开启、设备离线、存储保护等状态;管理员可直接进入当前镜头的录像设置。后台状态刷新不重建播放器。 +- 修正首次录像前目录不存在被误报为服务故障;仅匹配当前镜头的缺失目录,权限、损坏文件与真实服务错误仍报错。 +- 修正微秒时间转换为网页毫秒时精度丢失,避免查询起点落在片段之前导致 404。查询边界向录像范围内收敛,不填补无录像空档。 +- 回放响应关闭反向代理缓冲,并减少 FFmpeg 输入探测等待;保留双路转换并发限制与退出清理。 +- “持续录像”开关明确说明存储于服务器并受保留策略管理。 + +验证:173 项 Python 与 140 项 JavaScript 回归测试;Vue / Bootstrap 生产构建。包含时间边界、空录像目录、权限错误、容量暂停、状态显示及代理缓冲回归。 + +升级不重建业务数据库,不批量启用摄像头录制。已关闭录制期间的历史画面无法补录。 diff --git a/VERSION b/VERSION index 9f42295..5786113 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.37 +0.1.38 diff --git a/app.py b/app.py index f5353c8..93c92b8 100644 --- a/app.py +++ b/app.py @@ -73,6 +73,7 @@ SCAN = {'running': False, 'items': [], 'error': '', 'network': '', 'at': None} ATTEMPTS = {} API_PORT, HLS_PORT, PLAYBACK_PORT = 19997, 18888, 19996 MEDIA_AUTH = None +DEFAULT_STORAGE = {'retentionDays': 30, 'maxGB': 40, 'reserveGB': 8} class Problem(Exception): @@ -329,7 +330,7 @@ def source(c, quality): def write_media_config(): with LOCK: paths = {} - policy = setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) + policy = setting('storage', DEFAULT_STORAGE) for c in objects('cameras'): for quality in ('main', 'sub'): paths[stream_name(c, quality)] = { @@ -381,16 +382,24 @@ def media_json(path, playback=False): return json.load(r) except urllib.error.HTTPError as exc: with exc: - if playback and path.startswith('/list?') and exc.code == 404: + if playback and path.startswith('/list?') and exc.code in (400, 404): try: detail = json.loads(exc.read(4096)) - if detail.get('error') == 'no recording segments found': + message = detail.get('error') + if exc.code == 404 and message == 'no recording segments found': + return [] + # MediaMTX 1.21 returns 400 before this camera's first recording. + # Match only the requested camera directory, never permissions, + # another path, a corrupt file, or an unavailable service. + name = url.parse_qs(url.urlsplit(path).query).get('path', [''])[0] + if (exc.code == 400 and re.fullmatch(r'cam_[a-f0-9]{16}_main', name) + and message == 'lstat ' + str(RECORDINGS / name) + ': no such file or directory'): return [] except (ValueError, AttributeError): pass - raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) + raise Problem('录像服务暂不可用,请稍后重试或联系管理员检查服务', 503) except Exception: - raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) + raise Problem('录像服务暂不可用,请稍后重试或联系管理员检查服务', 503) def media_config_watch(): @@ -417,6 +426,22 @@ def media_config_watch(): time.sleep(5) +def recording_state(camera, ready, media_online): + if not camera.get('record'): + return 'off' + if not camera.get('enabled'): + return 'disabled' + if STORAGE['paused']: + return 'paused' + if not media_online: + return 'unavailable' + if not ready: + return 'offline' + if time.time() - RECENT_RECORDS.get(stream_name(camera), 0) < 45: + return 'recording' + return 'waiting' + + def status(): try: data = media_json('/v3/paths/list?itemsPerPage=200') @@ -432,10 +457,10 @@ def status(): if endpoint: c = dict(c, host=endpoint['host'], port=endpoint['port']) main = ready.get(stream_name(c), {}) + recording = recording_state(c, main.get('ready', False), media) cameras.append({**public_camera(c), 'ready': main.get('ready', False), 'tracks': main.get('tracks', []), 'recordingRequested': c['enabled'] and c['record'] and not STORAGE['paused'], - 'recordingActive': bool(c['enabled'] and c['record'] and not STORAGE['paused'] and main.get('ready', False) - and time.time() - RECENT_RECORDS.get(stream_name(c), 0) < 45)}) + 'recordingState': recording, 'recordingActive': recording == 'recording'}) with LOCK: storage = dict(STORAGE) scan = dict(SCAN) @@ -867,6 +892,7 @@ class Handler(BaseHTTPRequestHandler): self.connection.settimeout(15) self.send_response(200) self.send_header('Content-Type', 'video/mp4') + self.send_header('X-Accel-Buffering', 'no') self.send_header('Cache-Control', 'no-store') self.send_header('Accept-Ranges', 'none') self.send_header('X-Content-Type-Options', 'nosniff') @@ -1107,7 +1133,7 @@ def initialize(): HERITAGE.initialize() JOURNAL.initialize() if not setting('storage'): - set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) + set_setting('storage', DEFAULT_STORAGE) if not setting('mediaSecret'): set_setting('mediaSecret', secrets.token_hex(32)) MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode() diff --git a/frontend/package.json b/frontend/package.json index cd1f460..0ff572a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "zhao-steward-ui", "private": true, - "version": "0.1.37", + "version": "0.1.38", "type": "module", "scripts": { "build": "vite build", diff --git a/frontend/src/compat/load.js b/frontend/src/compat/load.js index 7a01b0a..a136bc2 100644 --- a/frontend/src/compat/load.js +++ b/frontend/src/compat/load.js @@ -15,6 +15,6 @@ export function enhance(root){ export async function loadControllers(){ enhance(document.getElementById('app')) new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true}) - for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.37';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)}) + for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.38';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)}) await window.ZhaoVue.boot() } diff --git a/frontend/src/components/RuntimeOverlays.vue b/frontend/src/components/RuntimeOverlays.vue index 830fab7..76829ee 100644 --- a/frontend/src/components/RuntimeOverlays.vue +++ b/frontend/src/components/RuntimeOverlays.vue @@ -4,7 +4,7 @@

设备档案

网络与连接

保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。

手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。

传承资料

这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。

空间档案

-

镜头通道配置

直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。

账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。

+

镜头通道配置

直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。

账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。

摄像头对象

网络与连接

保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。

录像机配置

网络与连接

保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。

驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。

负责人安排与交接

选择本家庭 / 场所的成员,分别指定主要和备用负责人。主要负责人即一家之主;选择普通成员时,需明确确认授予其本家庭管理权限。备用负责人可稍后安排。

交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。

diff --git a/frontend/src/components/Workspace.vue b/frontend/src/components/Workspace.vue index 283fd93..680dd81 100644 --- a/frontend/src/components/Workspace.vue +++ b/frontend/src/components/Workspace.vue @@ -34,6 +34,7 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'