Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84a84d9af0 | |||
| 2649854386 |
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
|
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
|
||||||
|
|
||||||
主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||||
|
|
||||||
## 管理 API
|
## 管理 API
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
|
|
||||||
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
|
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
|
||||||
|
|
||||||
HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。“高清 · 主码流兼容”使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 H.265 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
|
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 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
|
||||||
|
|
||||||
|
实时预览按镜头独立恢复连接:普通状态轮询只更新标签,不重建播放器;断流后按 2–30 秒退避重连,画面长时间不前进时也会自动恢复。用户主动暂停会保留暂停状态,离开页面会清理播放器和重试任务。
|
||||||
|
|
||||||
## Ubuntu x86_64 部署
|
## Ubuntu x86_64 部署
|
||||||
|
|
||||||
@@ -47,6 +49,8 @@ python3 app.py
|
|||||||
mediamtx ./data/mediamtx.yml
|
mediamtx ./data/mediamtx.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
回归测试:`python3 -m unittest -v test_app` 和 `node --test test_live_player.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 是单管理员模型。
|
访问 `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 是单管理员模型。
|
||||||
|
|
||||||
## 数据与恢复
|
## 数据与恢复
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
raise Problem('接口不存在', 404)
|
raise Problem('接口不存在', 404)
|
||||||
else:
|
else:
|
||||||
files = {'/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css',
|
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
|
||||||
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
'/vendor/hls.min.js': 'vendor/hls.min.js'}
|
||||||
if path not in files:
|
if path not in files:
|
||||||
raise Problem('页面不存在', 404)
|
raise Problem('页面不存在', 404)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import http.client
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -63,6 +64,13 @@ class WebTests(unittest.TestCase):
|
|||||||
self.assertEqual(headers['X-Frame-Options'], 'DENY')
|
self.assertEqual(headers['X-Frame-Options'], 'DENY')
|
||||||
self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
|
self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
|
||||||
|
|
||||||
|
def test_live_quality_has_only_main_and_sub(self):
|
||||||
|
code, _, body = self.request('/', False)
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
select = re.search(r'<select id="quality">(.*?)</select>', body.decode()).group(1)
|
||||||
|
self.assertEqual(re.findall(r'<option value="(.*?)">(.*?)</option>', select),
|
||||||
|
[('hd', '主码流'), ('compat', '子码流')])
|
||||||
|
|
||||||
def test_origin_rejected(self):
|
def test_origin_rejected(self):
|
||||||
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
|
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use strict';
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const LivePlayer = require('./web/live-player.js');
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
let now = 100, serial = 0;
|
||||||
|
const timeouts = new Map(), intervals = new Map();
|
||||||
|
const clock = {
|
||||||
|
setTimeout(fn, ms) {const id=++serial;timeouts.set(id,{fn,ms});return id;},
|
||||||
|
clearTimeout(id) {timeouts.delete(id);},
|
||||||
|
setInterval(fn, ms) {const id=++serial;intervals.set(id,{fn,ms});return id;},
|
||||||
|
clearInterval(id) {intervals.delete(id);}
|
||||||
|
};
|
||||||
|
const listeners = new Map(), indicators = {'.video-empty':{hidden:false},'.video-error':{hidden:true}};
|
||||||
|
const video = {
|
||||||
|
currentTime:0,readyState:0,videoWidth:0,paused:false,ended:false,error:null,
|
||||||
|
addEventListener(e,f) {listeners.set(e,f);},removeEventListener(e) {listeners.delete(e);},
|
||||||
|
emit(e) {listeners.get(e)?.();},
|
||||||
|
play() {this.paused=false;this.emit('play');return Promise.resolve();},
|
||||||
|
pause() {this.paused=true;this.emit('pause');},removeAttribute() {},load() {},
|
||||||
|
canPlayType() {return '';}
|
||||||
|
};
|
||||||
|
const instances=[];
|
||||||
|
class Hls {
|
||||||
|
static Events={ERROR:'error',MANIFEST_PARSED:'manifest'};
|
||||||
|
static isSupported() {return true;}
|
||||||
|
constructor() {this.events={};instances.push(this);}
|
||||||
|
on(e,f) {this.events[e]=f;}
|
||||||
|
loadSource() {} attachMedia() {} destroy() {this.destroyed=true;}
|
||||||
|
fail() {this.events.error(null,{fatal:true});}
|
||||||
|
}
|
||||||
|
const player=new LivePlayer(video,{querySelector:s=>indicators[s]},'/example',{Hls,clock,now:()=>now});
|
||||||
|
return {player,video,instances,timeouts,intervals,indicators,
|
||||||
|
advance(ms) {now+=ms;player.watch();},
|
||||||
|
playing() {video.readyState=4;video.videoWidth=1920;video.emit('playing');},
|
||||||
|
retry() {const [id,t]=timeouts.entries().next().value;timeouts.delete(id);now+=t.ms;t.fn();}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('readiness, recording status and labels do not change playback identity',()=>{
|
||||||
|
const camera={id:'one',enabled:true,ready:true,name:'A',recordingActive:false};
|
||||||
|
const original=LivePlayer.key([camera],'hd');
|
||||||
|
assert.equal(LivePlayer.key([{...camera,ready:false,name:'B',recordingActive:true}],'hd'),original);
|
||||||
|
assert.notEqual(LivePlayer.key([camera],'compat'),original);
|
||||||
|
});
|
||||||
|
test('fatal error reconnects only its own player with bounded backoff',()=>{
|
||||||
|
const a=fixture(),b=fixture();
|
||||||
|
for(let i=0;i<8;i++) {
|
||||||
|
a.instances.at(-1).fail();
|
||||||
|
assert.equal(a.timeouts.size,1);
|
||||||
|
assert.ok([...a.timeouts.values()][0].ms<=30000);
|
||||||
|
a.retry();
|
||||||
|
}
|
||||||
|
assert.equal(b.instances.length,1);
|
||||||
|
a.player.destroy();b.player.destroy();
|
||||||
|
});
|
||||||
|
test('duplicate errors schedule only one retry',()=>{
|
||||||
|
const f=fixture();f.instances[0].fail();f.video.emit('error');
|
||||||
|
assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||||
|
});
|
||||||
|
test('continuous playback does not restart during repeated health checks',()=>{
|
||||||
|
const f=fixture();f.playing();
|
||||||
|
for(let i=0;i<72;i++) {f.video.currentTime+=5;f.advance(5000);}
|
||||||
|
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);f.player.destroy();
|
||||||
|
});
|
||||||
|
test('stalled playback automatically reconnects',()=>{
|
||||||
|
const f=fixture();f.playing();f.advance(30000);
|
||||||
|
assert.equal(f.timeouts.size,1);f.retry();assert.equal(f.instances.length,2);f.player.destroy();
|
||||||
|
});
|
||||||
|
test('startup has enough time to wait for a source',()=>{
|
||||||
|
const f=fixture();f.advance(60000);assert.equal(f.timeouts.size,0);
|
||||||
|
f.advance(6000);assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||||
|
});
|
||||||
|
test('intentional pause is preserved and resume can recover a failed connection',()=>{
|
||||||
|
const f=fixture();f.playing();f.video.pause();f.instances[0].fail();f.advance(90000);
|
||||||
|
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);
|
||||||
|
f.video.play();assert.equal(f.instances.length,2);f.player.destroy();
|
||||||
|
});
|
||||||
|
test('destroy cancels timers and releases the old player',()=>{
|
||||||
|
const f=fixture();f.instances[0].fail();f.player.destroy();
|
||||||
|
assert.equal(f.timeouts.size,0);assert.equal(f.intervals.size,0);assert.equal(f.instances[0].destroyed,true);
|
||||||
|
});
|
||||||
|
test('recovery before timeout cancels pending retry',()=>{
|
||||||
|
const f=fixture();f.instances[0].fail();f.playing();
|
||||||
|
assert.equal(f.timeouts.size,0);assert.equal(f.indicators['.video-error'].hidden,true);f.player.destroy();
|
||||||
|
});
|
||||||
|
test('error from an old HLS instance cannot restart its replacement',()=>{
|
||||||
|
const f=fixture();const old=f.instances[0];old.fail();f.retry();old.fail();
|
||||||
|
assert.equal(f.timeouts.size,0);assert.equal(f.instances.length,2);f.player.destroy();
|
||||||
|
});
|
||||||
+17
-20
@@ -53,30 +53,27 @@ function render() {
|
|||||||
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
|
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
|
||||||
function renderLive() {
|
function renderLive() {
|
||||||
const cameras = filtered(), quality = $('#quality').value;
|
const cameras = filtered(), quality = $('#quality').value;
|
||||||
const key = JSON.stringify(cameras.map(c=>[c.id,c.name,c.siteId,c.point,c.ready,c.enabled,c.record,c.recordingActive])) + quality;
|
const key = ZhaoLivePlayer.key(cameras, quality);
|
||||||
if (key === liveKey) return;
|
if (key === liveKey) { updateLiveStatus(cameras); return; }
|
||||||
stopPlayers(); liveKey = key;
|
stopPlayers(); liveKey = key;
|
||||||
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button></div></article>`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>');
|
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button></div></article>`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>');
|
||||||
for (const c of cameras.filter(c=>c.enabled)) {
|
for (const c of cameras.filter(c=>c.enabled)) {
|
||||||
const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement;
|
const video = $(`video[data-camera="${c.id}"]`);
|
||||||
const src = `/media/live/cam_${c.id}_${quality}/index.m3u8`;
|
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
|
||||||
const error = text => {const el=panel.querySelector('.video-error');el.textContent=text;el.hidden=false;};
|
|
||||||
video.onplaying=()=>{panel.querySelector('.video-empty').hidden=true;panel.querySelector('.video-error').hidden=true;};
|
|
||||||
video.onerror=()=>error('浏览器无法解码此码流,请在清晰度中选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。');
|
|
||||||
if (window.Hls?.isSupported()) {
|
|
||||||
const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingTimeOut:60000,levelLoadingTimeOut:60000,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2});
|
|
||||||
hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){
|
|
||||||
const codecError = /codec|bufferAddCodec|manifestIncompatible/i.test(data.details || '');
|
|
||||||
error(codecError ? '浏览器不支持当前视频编码,请选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。' : '画面加载失败('+(data.details || data.type)+')。点击刷新状态可重试。');
|
|
||||||
console.warn('Video playback failed', c.id, data.type, data.details);
|
|
||||||
hls.destroy();
|
|
||||||
}});
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED,()=>video.play().catch(()=>{}));
|
|
||||||
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {video.src=src;video.play().catch(()=>{});}
|
|
||||||
else error('当前浏览器不支持 HLS 播放,请使用较新的浏览器。');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function updateLiveStatus(cameras) {
|
||||||
|
for (const c of cameras) {
|
||||||
|
const card=$(`video[data-camera="${c.id}"]`)?.closest('.camera-card');
|
||||||
|
if (!card) continue;
|
||||||
|
card.querySelector('.camera-meta strong').textContent=c.name;
|
||||||
|
card.querySelector('.camera-meta p').textContent=siteName(c.siteId)+' · '+(c.point || '安装位置待填写');
|
||||||
|
const badge=card.querySelector('.badge');
|
||||||
|
badge.textContent=c.recordingActive?'● 正在录像':c.ready?'码流已接通':c.enabled?'等待接通':'已停用';
|
||||||
|
badge.classList.toggle('off',!c.ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCameras() {
|
function renderCameras() {
|
||||||
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
|
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
|
||||||
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
|
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
|
||||||
@@ -106,7 +103,7 @@ $('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e
|
|||||||
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
|
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
|
||||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();resetPlayback();await auth();};
|
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();resetPlayback();await auth();};
|
||||||
$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
|
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
|
||||||
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
|
$('#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];}};
|
$('#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='';}
|
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='';}
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css">
|
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css">
|
||||||
<script defer src="/vendor/hls.min.js"></script><script defer src="/app.js"></script>
|
<script defer src="/vendor/hls.min.js"></script><script defer src="/live-player.js"></script><script defer src="/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div id="banner" class="banner" hidden></div>
|
<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>
|
<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>
|
<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="compat">流畅 · 兼容画面</option><option value="hd">高清 · 主码流兼容</option><option value="sub">原始 · 子码流</option><option value="main">原始 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">流畅画面最高 768×432;高清画面从主码流转换,最高 1920×1080、15 帧/秒。原始画面需要浏览器支持摄像头编码。</p><div id="live-grid" class="live-grid"></div></section>
|
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div id="live-grid" class="live-grid"></div></section>
|
||||||
<section id="page-playback" class="page" hidden>
|
<section id="page-playback" class="page" hidden>
|
||||||
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
|
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
|
||||||
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
|
<div class="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>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<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-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-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-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
|
||||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用 H.264 流畅画面(最高 768×432、10 帧/秒),也可选择从主码流转换的 1080p 高清画面。原始码流与本机录像保留设备编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></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>
|
||||||
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
|
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||||
</main>
|
</main>
|
||||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'use strict';
|
||||||
|
(function (root) {
|
||||||
|
class LivePlayer {
|
||||||
|
static key(cameras, quality) {
|
||||||
|
// Polling readiness, labels and recording status must not recreate video.
|
||||||
|
return JSON.stringify(cameras.map(c => [c.id, !!c.enabled])) + quality;
|
||||||
|
}
|
||||||
|
constructor(video, panel, source, options = {}) {
|
||||||
|
this.video = video; this.panel = panel; this.source = source;
|
||||||
|
this.Hls = options.Hls || root.Hls;
|
||||||
|
this.clock = options.clock || root;
|
||||||
|
this.now = options.now || (() => Date.now());
|
||||||
|
this.hls = null; this.timer = null; this.stopped = false;
|
||||||
|
this.attempt = 0; this.played = false; this.userPaused = false;
|
||||||
|
this.connecting = true; this.failed = false;
|
||||||
|
this.lastTime = 0; this.lastProgress = this.now(); this.stableSince = 0;
|
||||||
|
this.handlers = {
|
||||||
|
playing: () => {
|
||||||
|
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
this.played = true; this.connecting = false; this.failed = false;
|
||||||
|
this.userPaused = false; this.lastProgress = this.now();
|
||||||
|
this.lastTime = video.currentTime; this.stableSince = this.now();
|
||||||
|
panel.querySelector('.video-empty').hidden = true;
|
||||||
|
panel.querySelector('.video-error').hidden = true;
|
||||||
|
},
|
||||||
|
pause: () => { if (!this.connecting && this.played && !video.ended && !video.error && video.readyState >= 2) this.userPaused = true; },
|
||||||
|
play: () => { this.userPaused = false; this.lastProgress = this.now(); if (this.failed) this.retryIfNeeded(); },
|
||||||
|
error: () => this.retry('画面暂时中断'),
|
||||||
|
ended: () => this.retry('视频连接已结束')
|
||||||
|
};
|
||||||
|
for (const [event, handler] of Object.entries(this.handlers)) video.addEventListener(event, handler);
|
||||||
|
this.watchdog = this.clock.setInterval(() => this.watch(), 5000);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
message(text) {
|
||||||
|
const el = this.panel.querySelector('.video-error'); el.textContent = text; el.hidden = false;
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.connecting = true; this.failed = false;
|
||||||
|
this.release();
|
||||||
|
this.lastProgress = this.now(); this.lastTime = 0;
|
||||||
|
const Hls = this.Hls;
|
||||||
|
if (Hls?.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
maxBufferLength: 20, backBufferLength: 5,
|
||||||
|
liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 8,
|
||||||
|
maxLiveSyncPlaybackRate: 1.2,
|
||||||
|
manifestLoadingTimeOut: 60000, levelLoadingTimeOut: 15000,
|
||||||
|
fragLoadingTimeOut: 15000, manifestLoadingMaxRetry: 2, levelLoadingMaxRetry: 2
|
||||||
|
});
|
||||||
|
this.hls = hls;
|
||||||
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||||
|
if (this.hls === hls && data.fatal) this.retry('画面暂时中断');
|
||||||
|
});
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
if (this.hls === hls && !this.stopped) this.video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
hls.loadSource(this.source); hls.attachMedia(this.video);
|
||||||
|
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
this.video.src = this.source; this.video.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
this.userPaused = true;
|
||||||
|
this.message('当前浏览器不支持视频播放,请使用较新的浏览器。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retry(reason) {
|
||||||
|
if (this.stopped || this.timer !== null) return;
|
||||||
|
this.failed = true;
|
||||||
|
if (this.userPaused) return;
|
||||||
|
const delay = Math.min(30000, 2000 * 2 ** Math.min(this.attempt++, 4));
|
||||||
|
this.message(reason + ',正在自动重新连接…');
|
||||||
|
this.timer = this.clock.setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
if (!this.stopped) this.start();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
watch() {
|
||||||
|
if (this.stopped || this.timer !== null || this.userPaused) return;
|
||||||
|
const current = this.video.currentTime;
|
||||||
|
if (this.video.readyState >= 2 && this.video.videoWidth > 0 && current > this.lastTime + 0.1) {
|
||||||
|
this.lastTime = current; this.lastProgress = this.now();
|
||||||
|
if (this.stableSince && this.now() - this.stableSince > 30000) this.attempt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grace = this.connecting ? 65000 : 25000;
|
||||||
|
if (this.now() - this.lastProgress > grace) this.retry('画面停顿');
|
||||||
|
}
|
||||||
|
retryIfNeeded() {
|
||||||
|
if (!this.failed || this.stopped) return;
|
||||||
|
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = null; this.start();
|
||||||
|
}
|
||||||
|
release() {
|
||||||
|
const old = this.hls; this.hls = null;
|
||||||
|
if (old) old.destroy();
|
||||||
|
this.video.pause(); this.video.removeAttribute('src'); this.video.load();
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
this.stopped = true; this.connecting = true;
|
||||||
|
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||||
|
this.timer = null; this.clock.clearInterval(this.watchdog);
|
||||||
|
for (const [event, handler] of Object.entries(this.handlers)) this.video.removeEventListener(event, handler);
|
||||||
|
this.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = LivePlayer;
|
||||||
|
else root.ZhaoLivePlayer = LivePlayer;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
Reference in New Issue
Block a user