diff --git a/README.md b/README.md
index aabd558..b271933 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
本机录像默认关闭,用户逐通道开启。默认保留 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 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
+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 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
## Ubuntu x86_64 部署
diff --git a/VERSION b/VERSION
index c946ee6..1180819 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.6
+0.1.7
diff --git a/app.py b/app.py
index 944b479..5d60acf 100644
--- a/app.py
+++ b/app.py
@@ -26,6 +26,7 @@ import urllib.parse as url
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import onvif
+import playback
ROOT = Path(__file__).resolve().parent
os.umask(0o077)
@@ -79,7 +80,8 @@ def set_setting(key, value):
def objects(table):
with LOCK:
- return [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
+ rows = [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
+ return sorted(rows, key=lambda row: (row.get('name', ''), row['id']))
def get_object(table, key):
@@ -594,7 +596,7 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
- def proxy(self, port, path):
+ def proxy(self, port, path, download=False):
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=55)
headers = {'Authorization': MEDIA_AUTH}
if self.headers.get('Range'):
@@ -615,6 +617,8 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('视频服务返回了无效的播放跳转', 502)
location = '/media/live' + target.path + ('?' + target.query if target.query else '')
self.send_response(r.status)
+ if download:
+ self.send_header('Content-Disposition', 'attachment; filename="recording.mp4"')
if location:
self.send_header('Location', location)
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
@@ -635,6 +639,36 @@ class Handler(BaseHTTPRequestHandler):
finally:
conn.close()
+ def compatible_playback(self, path, duration):
+ started = False
+ old_timeout = self.connection.gettimeout()
+ try:
+ with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration) as output:
+ first = output.read1(65536)
+ if not first:
+ raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503)
+ self.connection.settimeout(15)
+ self.send_response(200)
+ self.send_header('Content-Type', 'video/mp4')
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('Accept-Ranges', 'none')
+ self.send_header('X-Content-Type-Options', 'nosniff')
+ self.send_header('Connection', 'close')
+ self.end_headers()
+ started = True
+ self.close_connection = True
+ self.wfile.write(first)
+ while chunk := output.read1(65536):
+ self.wfile.write(chunk)
+ except playback.PlaybackError as exc:
+ if not started:
+ raise Problem(str(exc), 503)
+ except OSError:
+ if not started:
+ raise Problem('录像转换连接失败', 503)
+ finally:
+ self.connection.settimeout(old_timeout)
+
def do_GET(self):
try:
parsed = url.urlsplit(self.path)
@@ -681,9 +715,13 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError()
except ValueError:
raise Problem('单次回放最多一小时')
+ download = query.get('download') == '1'
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'duration': duration,
- 'format': 'mp4' if query.get('download') == '1' else 'fmp4'})
- self.proxy(PLAYBACK_PORT, '/get?' + q)
+ 'format': 'mp4' if download else 'fmp4'})
+ if download:
+ self.proxy(PLAYBACK_PORT, '/get?' + q, download=True)
+ else:
+ self.compatible_playback('/get?' + q, duration)
else:
raise Problem('接口不存在', 404)
else:
diff --git a/deploy/zhaovision-media.service b/deploy/zhaovision-media.service
index 7ed45ce..756a4e2 100644
--- a/deploy/zhaovision-media.service
+++ b/deploy/zhaovision-media.service
@@ -13,6 +13,7 @@ EnvironmentFile=/etc/zhaovision.env
ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml
Restart=always
RestartSec=4
+TimeoutStopSec=15s
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
diff --git a/deploy/zhaovision.service b/deploy/zhaovision.service
index e6efc38..74df561 100644
--- a/deploy/zhaovision.service
+++ b/deploy/zhaovision.service
@@ -21,7 +21,8 @@ PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/zhaovision
-MemoryMax=512M
+MemoryMax=900M
+TimeoutStopSec=15s
[Install]
WantedBy=multi-user.target
diff --git a/playback.py b/playback.py
new file mode 100644
index 0000000..6af941c
--- /dev/null
+++ b/playback.py
@@ -0,0 +1,70 @@
+"""Bounded, ephemeral H.264 playback; original recordings remain untouched."""
+from contextlib import contextmanager
+import os
+import shutil
+import subprocess
+import threading
+
+SLOTS = threading.BoundedSemaphore(2)
+
+
+class PlaybackError(Exception):
+ pass
+
+
+def command(source, authorization, duration):
+ executable = shutil.which('ffmpeg')
+ if not executable:
+ raise PlaybackError('回放转换需要服务器安装 FFmpeg')
+ device = os.environ.get('VISION_VAAPI_DEVICE', '/dev/dri/renderD128')
+ acceleration = []
+ if os.access(device, os.R_OK | os.W_OK):
+ acceleration = ['-hwaccel', 'vaapi', '-hwaccel_device', device,
+ '-hwaccel_output_format', 'vaapi', '-extra_hw_frames', '8']
+ video = ['-vf', 'fps=15,scale_vaapi=w=1920:h=1080:format=nv12:force_original_aspect_ratio=decrease:force_divisible_by=2',
+ '-c:v', 'h264_vaapi', '-profile:v', 'main', '-level:v', '4.1',
+ '-rc_mode', 'CQP', '-qp', '25', '-g', '30', '-bf', '0']
+ else:
+ video = ['-vf', 'fps=10,scale=768:432:force_original_aspect_ratio=decrease:force_divisible_by=2',
+ '-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
+ '-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
+ '-crf', '26', '-g', '20', '-bf', '0']
+ return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
+ '-threads', '1', '-filter_threads', '1'] + acceleration + [
+ '-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
+ '-readrate', '1', '-i', source, '-t', str(duration),
+ '-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
+ '-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
+ '-movflags', '+frag_keyframe+empty_moov+default_base_moof',
+ '-frag_duration', '1000000', '-f', 'mp4', 'pipe:1']
+
+
+@contextmanager
+def stream(source, authorization, duration):
+ if not SLOTS.acquire(blocking=False):
+ raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试')
+ process = timer = None
+ try:
+ # No shell and no diagnostics containing authentication headers in logs.
+ process = subprocess.Popen(command(source, authorization, duration),
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL)
+ timer = threading.Timer(duration + 45, process.kill)
+ timer.daemon = True
+ timer.start()
+ yield process.stdout
+ except OSError as exc:
+ raise PlaybackError('录像转换服务暂不可用') from exc
+ finally:
+ if timer:
+ timer.cancel()
+ if process:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ process.stdout.close()
+ SLOTS.release()
diff --git a/test_app.py b/test_app.py
new file mode 100644
index 0000000..918e312
--- /dev/null
+++ b/test_app.py
@@ -0,0 +1,133 @@
+"""Isolated regression tests: python -m unittest -v test_app."""
+import hashlib
+import http.client
+import json
+import os
+import tempfile
+import threading
+import time
+import unittest
+from unittest.mock import patch
+
+_data = tempfile.TemporaryDirectory()
+os.environ['VISION_DATA'] = _data.name
+os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
+import app
+import playback
+
+
+class WebTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ app.initialize()
+ cls.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
+ cls.thread.start()
+ app.DB.execute('INSERT INTO sessions VALUES (?,?)',
+ (hashlib.sha256(b'f' * 64).hexdigest(), time.time() + 60))
+ app.DB.commit()
+ app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'password': 'private'})
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown()
+ cls.server.server_close()
+ cls.thread.join()
+ app.DB.close()
+ _data.cleanup()
+
+ def request(self, path, authenticated=True, data=None, origin=None):
+ c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
+ headers = {'Cookie': 'vision=' + 'f' * 64} if authenticated else {}
+ if origin:
+ headers['Origin'] = origin
+ if data is not None:
+ headers['Content-Type'] = 'application/json'
+ c.request('GET' if data is None else 'POST', path,
+ None if data is None else json.dumps(data), headers)
+ r = c.getresponse()
+ result = r.status, dict(r.getheaders()), r.read()
+ c.close()
+ return result
+
+ def test_media_and_api_require_login(self):
+ for path in ['/api/state', '/api/recordings', '/media/playback',
+ '/media/live/cam_' + 'a' * 16 + '_hd/index.m3u8']:
+ with self.subTest(path=path):
+ self.assertEqual(self.request(path, False)[0], 401)
+
+ def test_static_security_headers(self):
+ code, headers, _ = self.request('/', False)
+ self.assertEqual(code, 200)
+ self.assertEqual(headers['X-Frame-Options'], 'DENY')
+ self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
+
+ def test_origin_rejected(self):
+ self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
+
+ def test_invalid_ranges_and_paths(self):
+ base = '/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration='
+ for duration in ['nan', 'inf', '-1', '3601']:
+ self.assertEqual(self.request(base + duration)[0], 400)
+ self.assertEqual(self.request('/media/live/../../vision.db')[0], 400)
+ self.assertEqual(self.request('/vision.db', False)[0], 404)
+
+ def test_compatible_playback_dispatch(self):
+ calls = []
+ def compatible(handler, path, duration):
+ calls.append((path, duration))
+ handler.answer({'ok': True})
+ with patch.object(app.Handler, 'compatible_playback', compatible):
+ code, _, _ = self.request('/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10')
+ self.assertEqual(code, 200)
+ self.assertEqual(calls[0][1], 10)
+ self.assertIn('format=fmp4', calls[0][0])
+
+ def test_original_download_dispatch(self):
+ calls = []
+ def proxy(handler, port, path, download=False):
+ calls.append((path, download))
+ handler.answer({'ok': True})
+ with patch.object(app.Handler, 'proxy', proxy):
+ code, _, _ = self.request('/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10&download=1')
+ self.assertEqual(code, 200)
+ self.assertIn('format=mp4', calls[0][0])
+ self.assertTrue(calls[0][1])
+
+ def test_stable_object_order(self):
+ app.save_object('sites', {'id': 'b', 'name': 'Second'})
+ app.save_object('sites', {'id': 'a', 'name': 'First'})
+ first = app.objects('sites')
+ app.save_object('sites', first[0])
+ self.assertEqual(app.objects('sites'), first)
+
+ def test_password_redacted(self):
+ self.assertNotIn('password', app.public_camera(app.get_object('cameras', 'a' * 16)))
+
+ def test_playback_has_bound_and_cleanup(self):
+ playback.SLOTS.acquire()
+ playback.SLOTS.acquire()
+ try:
+ with self.assertRaises(playback.PlaybackError):
+ with playback.stream('unused', 'private', 10):
+ self.fail('No third conversion allowed')
+ finally:
+ playback.SLOTS.release()
+ playback.SLOTS.release()
+ with patch('playback.shutil.which', return_value=None):
+ with self.assertRaises(playback.PlaybackError):
+ with playback.stream('unused', 'private', 10):
+ pass
+ self.assertTrue(playback.SLOTS.acquire(blocking=False))
+ playback.SLOTS.release()
+
+ def test_vaapi_uses_supported_cqp(self):
+ with patch('playback.shutil.which', return_value='ffmpeg'), patch('playback.os.access', return_value=True):
+ command = playback.command('http://127.0.0.1/source', 'private', 10)
+ self.assertEqual(command[command.index('-rc_mode') + 1], 'CQP')
+ self.assertIn('h264_vaapi', command)
+ self.assertIn('pipe:1', command)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/web/app.js b/web/app.js
index 0c553ac..33dd049 100644
--- a/web/app.js
+++ b/web/app.js
@@ -24,7 +24,7 @@ function showPage(page) {
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
- if (page !== 'playback') $('#play-video').pause();
+ if (page !== 'playback') resetPlayback();
render();
if (page === 'storage') loadAudit();
}
@@ -84,7 +84,8 @@ function renderCameras() {
$('#camera-list').innerHTML=cameras.length?`
| 设备 / 位置 | 所属空间 | 地址 | 码流状态 | 录像 | |
${cameras.map(c=>`| ${esc(c.name)}${esc(c.point||'位置待完善')} | ${esc(siteName(c.siteId))} | ${esc(c.host)}:${c.port} | ${c.ready?'已接通':c.enabled?'未接通':'停用'} | ${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'} | |
`).join('')}
`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
}
function renderSites() {
- $('#site-list').innerHTML=state.sites.length?state.sites.map(s=>`${kind(s.kind)}
${esc(s.name)}
- 所属
- ${esc(s.owner||'待填写')}
- 地区
- ${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}
- 小区 / 园区
- ${esc(s.community||'待填写')}
- 楼栋门牌
- ${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}
- 摄像头
- ${state.cameras.filter(c=>c.siteId===s.id).length} 台
- 详细地址
- ${esc(s.address||'待填写')}
`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','');
+ const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
+ $('#site-list').innerHTML=sites.length?sites.map(s=>`${kind(s.kind)}
${esc(s.name)}
- 所属
- ${esc(s.owner||'待填写')}
- 地区
- ${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}
- 小区 / 园区
- ${esc(s.community||'待填写')}
- 楼栋门牌
- ${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}
- 摄像头
- ${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头
- 详细地址
- ${esc(s.address||'待填写')}
`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','');
}
function renderStorage() {
const s=state.storage;
@@ -103,12 +104,12 @@ handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enable
handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit);
$('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#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;queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
-$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();$('#play-video').pause();await auth();};
-$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
+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();};
$('#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() {const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';drawTimeline();$('#recording-list').innerHTML='';}
+function resetPlayback() {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 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;}
@@ -117,21 +118,24 @@ async function queryRecordings() {
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));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){$('#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;}
+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=()=>{$('#play-info').textContent='回放失败:可能没有完整录像,或浏览器不支持录像编码;可下载片段后播放。';};
+$('#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);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);auth();
function renderAssets(){
- $('#asset-list').innerHTML=state.assets.length?state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value).map(a=>`摄像头对象
${esc(a.name)}
- 所属空间
- ${esc(siteName(a.siteId))}
- 品牌型号
- ${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}
- 物理镜头
- ${a.lensCount} 个
- 已配置通道
- ${state.cameras.filter(c=>c.assetId===a.id).length} 个
- 安装位置
- ${esc(a.point||'待填写')}
`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','');
+ const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
+ $('#asset-list').innerHTML=assets.length?assets.map(a=>`摄像头对象
${esc(a.name)}
- 所属空间
- ${esc(siteName(a.siteId))}
- 品牌型号
- ${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}
- 物理镜头
- ${a.lensCount} 个
- 已配置通道
- ${state.cameras.filter(c=>c.assetId===a.id).length} 个
- 安装位置
- ${esc(a.point||'待填写')}
`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','');
}
function renderRecorders(){
- $('#recorder-list').innerHTML=state.recorders.length?state.recorders.map(r=>`录像机
${esc(r.name)}
- 所属空间
- ${esc(siteName(r.siteId))}
- 品牌型号
- ${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}
- 设备地址
- ${esc(r.host)}:${r.port}
- 凭据
- ${r.passwordConfigured?'已配置':'尚未填写'}
- 关联镜头
- ${state.cameras.filter(c=>c.recorderId===r.id).length} 个
- 原有录像回放
- 待型号适配
`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','');
+ const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
+ $('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`录像机
${esc(r.name)}
- 所属空间
- ${esc(siteName(r.siteId))}
- 品牌型号
- ${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}
- 设备地址
- ${esc(r.host)}:${r.port}
- 凭据
- ${r.passwordConfigured?'已配置':'尚未填写'}
- 关联镜头
- ${state.cameras.filter(c=>c.recorderId===r.id).length} 个
- 原有录像回放
- 待型号适配
`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','');
}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
diff --git a/web/index.html b/web/index.html
index e95eb6d..f4a9298 100644
--- a/web/index.html
+++ b/web/index.html
@@ -29,7 +29,7 @@
回到那一刻 PLAYBACK
选择日期,查看实际录像时段
- 当日时间轴 有录像的时段 · 点击定位
00:0006:0012:0018:0024:00
+ 当日时间轴 有录像的时段 · 点击定位
00:0006:0012:0018:0024:00