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=>``).join('')}
设备 / 位置所属空间地址码流状态录像
${esc(c.name)}${esc(c.point||'位置待完善')}${esc(siteName(c.siteId))}${esc(c.host)}:${c.port}${c.ready?'已接通':c.enabled?'未接通':'停用'}${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}
`: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