From 84a84d9af0afb61927e45e5f058afd5932702c6c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 17:26:41 +0800 Subject: [PATCH] Keep live players stable across polls and automatically recover each stream --- README.md | 4 ++ VERSION | 2 +- app.py | 2 +- test_live_player.js | 91 ++++++++++++++++++++++++++++++++++++ web/app.js | 37 +++++++-------- web/index.html | 2 +- web/live-player.js | 110 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 test_live_player.js create mode 100644 web/live-player.js diff --git a/README.md b/README.md index 13c0c01..7ef4f00 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ 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 部署 先将源文件提交、公开发布,再部署同一 Git 提交的归档。建议放在 `/opt/zhaovision/releases//`。Python 3.11 以上,无 pip 运行依赖。 @@ -47,6 +49,8 @@ python3 app.py 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 是单管理员模型。 ## 数据与恢复 diff --git a/VERSION b/VERSION index 1a03094..9767cc9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.9 +0.1.10 diff --git a/app.py b/app.py index 00be234..3c5d4e4 100644 --- a/app.py +++ b/app.py @@ -734,7 +734,7 @@ class Handler(BaseHTTPRequestHandler): else: raise Problem('接口不存在', 404) 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'} if path not in files: raise Problem('页面不存在', 404) diff --git a/test_live_player.js b/test_live_player.js new file mode 100644 index 0000000..b697089 --- /dev/null +++ b/test_live_player.js @@ -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(); +}); diff --git a/web/app.js b/web/app.js index 6a72a0d..4a5fd23 100644 --- a/web/app.js +++ b/web/app.js @@ -53,30 +53,27 @@ function render() { function empty(title, description, button) { return `

${esc(title)}

${esc(description)}

${button || ''}
`; } function renderLive() { 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; - if (key === liveKey) return; + 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('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''); for (const c of cameras.filter(c=>c.enabled)) { - const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement; - const src = `/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 播放,请使用较新的浏览器。'); + const video = $(`video[data-camera="${c.id}"]`); + players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`)); } } +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() { $('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测'; $('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`
${esc(s.host)} · RTSP 服务响应
`).join('') || (state.scan.at ? '

本次没有发现响应 RTSP 554 端口的设备。

' : ''); @@ -106,7 +103,7 @@ $('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e $('#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));}); $('#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);}}; $('#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='';} diff --git a/web/index.html b/web/index.html index 06b7250..012664a 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ 赵府智家 · 视觉 - +