Compare commits

...

4 Commits

23 changed files with 297 additions and 35 deletions
+7
View File
@@ -0,0 +1,7 @@
# v0.1.39 — 手机录像筛选布局
在 v0.1.38 录像修复基础上调整小屏幕回放表单:镜头、录像来源、日期和查询按钮纵向排列,控件保持完整宽度,避免三个选择框挤在一行。
本版仅调整前端;保留 v0.1.38 的 30 天默认留存、容量保护、录像状态与时间精度修复。设备数据及录像配置不迁移。生产更新仅重启网页服务,持续录像的 MediaMTX 进程保留。
验证:141 项 JavaScript 回归测试与 Vue / Bootstrap 生产构建通过;后端沿用已通过 173 项回归的 v0.1.38 实现。
+10
View File
@@ -0,0 +1,10 @@
# v0.1.40 · 家谱画布稳定性与连续回放修复
- 修复全屏家谱因小数像素取整而反复产生滚动条、重算居中的抖动。
- 合并画布尺寸通知,尺寸不变时不重复适配;手动缩放与拖动位置保持。
- 修复浏览器不支持原生全屏时,兼容全屏画布被侧栏遮挡的问题。
- 保留手机端家谱画布及人物档案的独立滚动区域。
- 修复录像回放以直播速度读取历史 HTTP 文件,造成长片段上游写入超时而提前截断的问题;保留转换并发、线程和生命周期上限。
- 新增画布小数像素、尺寸通知合并、手动视角、隐藏和重置回归测试。
录像保留策略仍以当前平台设置为准:30 天为最长保留时间;录像容量上限和磁盘预留可提前触发暂停,不能据此保证存满 30 天。
+10
View File
@@ -0,0 +1,10 @@
# v0.1.41 · 录像按需缓冲与连续回放
- 修复浏览器减慢读取时,长录像源 HTTP 连接超时而提前结束的问题。
- 回放以最多 5 分钟为一段,沿用自动续播和真实录像空档规则;原始片段下载仍可选择最多一小时。
- 每段录像在服务器私有临时目录缓冲,转换结束、失败或离开页面后清理;最多两个转换、每份缓存最大 256 MiB,受磁盘预留空间及下载时限保护。
- 转换进程仅访问临时文件,按播放速度读取并限制线程,不携带录像服务认证信息。
- 长时间暂停后从当前播放位置重新接入,无需刷新整个页面。
- 保留 v0.1.40 家谱全屏抖动、尺寸通知循环与侧栏遮挡修复。
30 天为录像最长保留时间。实际可保留时长仍受总录像容量上限、磁盘预留、码率和路数限制。
+5
View File
@@ -0,0 +1,5 @@
# v0.1.42 · 回放预缓冲调整
- 允许有限录像片段提前完成转换,减少逐秒限速造成的播放器缓冲等待。
- 继续使用最多 5 分钟的分段播放、自动续播、两路转换并发、单线程处理及有界临时缓存。
- 保留家谱全屏稳定性修复、30 天录像保留上限、磁盘容量保护和长暂停后的原位置恢复。
+1 -1
View File
@@ -1 +1 @@
0.1.38 0.1.42
+2 -1
View File
@@ -885,7 +885,8 @@ class Handler(BaseHTTPRequestHandler):
started = False started = False
old_timeout = self.connection.gettimeout() old_timeout = self.connection.gettimeout()
try: try:
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration) as output: reserve_bytes = int(setting('storage', DEFAULT_STORAGE).get('reserveGB', 8) * 1024**3)
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration, reserve_bytes=reserve_bytes) as output:
first = output.read1(65536) first = output.read1(65536)
if not first: if not first:
raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503) raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "zhao-steward-ui", "name": "zhao-steward-ui",
"private": true, "private": true,
"version": "0.1.38", "version": "0.1.42",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
+5
View File
@@ -219,6 +219,11 @@ html[data-density="compact"] .card-body { padding: 18px; }
.home-card-actions { gap: 8px; flex-wrap: wrap; } .home-card-actions { gap: 8px; flex-wrap: wrap; }
.btn { min-height: 44px; } .btn { min-height: 44px; }
.form-control, .form-select { min-height: 44px; font-size: 16px; } .form-control, .form-select { min-height: 44px; font-size: 16px; }
#page-playback .playback-controls { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
#page-playback .playback-controls label { width: 100%; min-width: 0; }
#page-playback .playback-controls input, #page-playback .playback-controls select { width: 100%; }
#page-playback .playback-controls button { width: 100%; }
#play-info { overflow-wrap: anywhere; }
.device-view-controls { padding: 16px; } .device-view-controls { padding: 16px; }
.network-map { padding: 14px; } .network-map { padding: 14px; }
.network-platform { padding: 16px; } .network-platform { padding: 16px; }
+1 -1
View File
@@ -15,6 +15,6 @@ export function enhance(root){
export async function loadControllers(){ export async function loadControllers(){
enhance(document.getElementById('app')) enhance(document.getElementById('app'))
new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true}) new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.38';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)}) for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.42';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
await window.ZhaoVue.boot() await window.ZhaoVue.boot()
} }
+67 -7
View File
@@ -1,17 +1,60 @@
"""Bounded, ephemeral H.264 playback; original recordings remain untouched.""" """Bounded, ephemeral H.264 playback; original recordings remain untouched."""
from contextlib import contextmanager from contextlib import contextmanager
import http.client
import os import os
import shutil import shutil
import subprocess import subprocess
import threading import threading
import tempfile
import time
import urllib.request
from pathlib import Path
SLOTS = threading.BoundedSemaphore(2) SLOTS = threading.BoundedSemaphore(2)
MAX_SOURCE_BYTES = 256 * 1024 * 1024
MAX_SESSION_SECONDS = 300
class PlaybackError(Exception): class PlaybackError(Exception):
pass pass
@contextmanager
def cached_source(source, authorization, reserve_bytes):
# A private, bounded scratch file decouples MediaMTX's HTTP write timeout
# from browser buffering and pause. Use the data disk, not a small /tmp RAM disk.
base = Path(os.environ.get('VISION_DATA', tempfile.gettempdir())) / 'playback-tmp'
base.mkdir(mode=0o700, parents=True, exist_ok=True)
for old in base.glob('buffer-*'):
try:
if old.is_symlink() or not old.is_dir() or time.time() - old.stat().st_mtime < 3600:
continue
(old / 'source.mp4').unlink(missing_ok=True)
old.rmdir()
except OSError:
pass
with tempfile.TemporaryDirectory(prefix='buffer-', dir=base) as folder:
if shutil.disk_usage(folder).free < reserve_bytes + MAX_SOURCE_BYTES:
raise PlaybackError('磁盘剩余空间不足以缓冲回放,请先释放空间')
request = urllib.request.Request(source, headers={'Authorization': authorization})
target = Path(folder) / 'source.mp4'
deadline = time.monotonic() + 30
size = 0
with urllib.request.urlopen(request, timeout=10) as response, target.open('wb') as output:
while chunk := response.read(256 * 1024):
size += len(chunk)
if size > MAX_SOURCE_BYTES:
raise PlaybackError('该录像片段过大,请缩短回放时段')
if time.monotonic() > deadline:
raise PlaybackError('回放缓冲超时,请稍后重试')
if shutil.disk_usage(folder).free < reserve_bytes + len(chunk):
raise PlaybackError('磁盘预留空间不足,已停止缓冲回放')
output.write(chunk)
if not size:
raise PlaybackError('该时段没有可读取的录像')
yield str(target)
def command(source, authorization, duration): def command(source, authorization, duration):
executable = shutil.which('ffmpeg') executable = shutil.which('ffmpeg')
if not executable: if not executable:
@@ -29,11 +72,14 @@ def command(source, authorization, duration):
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast', '-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
'-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p', '-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
'-crf', '26', '-g', '20', '-bf', '0'] '-crf', '26', '-g', '20', '-bf', '0']
input_options = (['-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n']
if authorization else [])
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin', return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
'-threads', '1', '-filter_threads', '1'] + acceleration + [ '-threads', '1', '-filter_threads', '1'] + acceleration + input_options + [
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
'-analyzeduration', '1000000', '-probesize', '1000000', '-analyzeduration', '1000000', '-probesize', '1000000',
'-readrate', '1', '-i', source, '-t', str(duration), # Recordings are finite HTTP downloads. Pacing their input like a
# live source stalls upstream writes and truncates long responses.
'-i', source, '-t', str(duration),
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [ '-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k', '-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
'-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-movflags', '+frag_keyframe+empty_moov+default_base_moof',
@@ -41,20 +87,34 @@ def command(source, authorization, duration):
@contextmanager @contextmanager
def stream(source, authorization, duration): def stream(source, authorization, duration, reserve_bytes=8 * 1024**3):
if not 0 < duration <= MAX_SESSION_SECONDS:
raise PlaybackError('请刷新页面,使用每段最多 5 分钟的自动连续回放')
if not SLOTS.acquire(blocking=False): if not SLOTS.acquire(blocking=False):
raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试') raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试')
process = timer = None process = timer = None
try: try:
# No shell and no diagnostics containing authentication headers in logs. if not shutil.which('ffmpeg'):
process = subprocess.Popen(command(source, authorization, duration), raise PlaybackError('回放转换需要服务器安装 FFmpeg')
with cached_source(source, authorization, reserve_bytes) as local_source:
# Credentials never appear in the converter's process arguments.
process = subprocess.Popen(command(local_source, '', duration),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL) stderr=subprocess.DEVNULL)
timer = threading.Timer(duration + 45, process.kill) timer = threading.Timer(duration + 45, process.kill)
timer.daemon = True timer.daemon = True
timer.start() timer.start()
try:
yield process.stdout yield process.stdout
except OSError as exc: finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
except (OSError, http.client.HTTPException) as exc:
raise PlaybackError('录像转换服务暂不可用') from exc raise PlaybackError('录像转换服务暂不可用') from exc
finally: finally:
if timer: if timer:
+2
View File
@@ -258,6 +258,8 @@ class WebTests(unittest.TestCase):
self.assertIn('h264_vaapi', command) self.assertIn('h264_vaapi', command)
self.assertIn('pipe:1', command) self.assertIn('pipe:1', command)
self.assertEqual(command[command.index('-analyzeduration') + 1], '1000000') self.assertEqual(command[command.index('-analyzeduration') + 1], '1000000')
self.assertNotIn('-readrate', command)
self.assertNotIn('-re', command)
def test_ptz_requires_both_space_and_control_permission(self): def test_ptz_requires_both_space_and_control_permission(self):
app.save_object('sites', dict(id='permitted', name='Permitted')) app.save_object('sites', dict(id='permitted', name='Permitted'))
+37
View File
@@ -0,0 +1,37 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
function fixture(){
const space={style:{}},surface={style:{}},label={},frames=new Map();let next=0,observer;
const viewport={clientWidth:1550,clientHeight:601,rect:{width:1550,height:600.5234375},scrollLeft:0,scrollTop:0,
addEventListener(){},getBoundingClientRect(){return this.rect;},
querySelector(s){return s==='.family-map-space'?space:surface;},replaceChildren(){},
scrollTo(x,y){this.scrollLeft=x;this.scrollTop=y;}};
const scope=vm.createContext({$:()=>label,ResizeObserver:class{constructor(fn){observer=fn;}observe(){}},
requestAnimationFrame(fn){frames.set(++next,fn);return next;},cancelAnimationFrame(id){frames.delete(id);}});
vm.runInContext(fs.readFileSync('web/family-map.js','utf8')+';this.MapClass=FamilyMap',scope);
const map=new scope.MapClass(viewport,()=>{});map.graph={width:400,height:264,nodes:[]};
return {map,viewport,space,surface,frames,resize:()=>observer(),flush(){const jobs=[...frames.values()];frames.clear();jobs.forEach(fn=>fn());}};
}
test('fractional fullscreen size never creates a too-tall scroll surface',()=>{
const f=fixture();for(let i=0;i<20;i++){f.map.fit();assert.equal(f.space.style.height,'600px');assert.ok(parseFloat(f.space.style.height)<=f.viewport.rect.height);}
assert.equal(f.surface.style.transform,'translate(575px,168px) scale(1)');
});
test('resize notifications coalesce and unchanged dimensions do not refit',()=>{
const f=fixture();let calls=0;const fit=f.map.fit.bind(f.map);f.map.fit=()=>{calls++;fit();};
f.resize();f.resize();assert.equal(f.frames.size,1);f.flush();assert.equal(calls,1);
f.resize();f.flush();assert.equal(calls,1);
f.viewport.rect.height=700.7;f.viewport.clientHeight=701;f.resize();f.flush();assert.equal(calls,2);
});
test('manual zoom and scroll survive a resize notification',()=>{
const f=fixture();f.map.autoFit=false;f.map.scale=1.5;f.viewport.scrollLeft=15;f.viewport.scrollTop=30;
f.resize();f.flush();assert.equal(f.map.scale,1.5);assert.equal(f.map.autoFit,false);
assert.equal(f.viewport.scrollLeft,15);assert.equal(f.viewport.scrollTop,30);
});
test('hidden canvas does not resize and pending work is cancelled on reset',()=>{
const f=fixture();f.viewport.clientWidth=0;f.resize();f.flush();assert.equal(f.space.style.width,undefined);
f.resize();f.map.reset();assert.equal(f.frames.size,0);assert.equal(f.map.graph,null);
});
test('scaled map clips unscaled overflow and fullscreen fallback clears the sidebar',()=>{
const css=fs.readFileSync('web/family-map.css','utf8');
assert.match(css,/\.family-map-space\{min-width:0;min-height:0;overflow:clip\}/);
assert.match(css,/scrollbar-gutter:stable/);assert.match(css,/\.family-workspace\.family-expanded\{z-index:1100\}/);
});
+74
View File
@@ -0,0 +1,74 @@
import io
import os
import time
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import playback
class PlaybackCacheTests(unittest.TestCase):
def test_private_cache_removed_after_use_and_abort(self):
for abort in (False, True):
with tempfile.TemporaryDirectory() as folder:
with patch.dict('os.environ', {'VISION_DATA':folder}), patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=10**12)), patch('playback.urllib.request.urlopen', return_value=io.BytesIO(b'video')) as opening:
try:
with playback.cached_source('http://127.0.0.1/source', 'Basic private', 8) as path:
self.assertEqual(Path(path).read_bytes(), b'video')
self.assertEqual(opening.call_args.args[0].get_header('Authorization'), 'Basic private')
if abort:
raise RuntimeError('browser left')
except RuntimeError:
self.assertTrue(abort)
self.assertEqual(list((Path(folder)/'playback-tmp').iterdir()), [])
def test_oversize_and_empty_sources_leave_no_cache(self):
for contents in (b'', b'123456789'):
with tempfile.TemporaryDirectory() as folder:
with patch.dict('os.environ', {'VISION_DATA':folder}), patch('playback.MAX_SOURCE_BYTES', 8), patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=1000)), patch('playback.urllib.request.urlopen', return_value=io.BytesIO(contents)):
with self.assertRaises(playback.PlaybackError):
with playback.cached_source('http://127.0.0.1/source', 'private', 8):
self.fail('invalid cache accepted')
self.assertEqual(list((Path(folder)/'playback-tmp').iterdir()), [])
def test_disk_reserve_checked_before_download(self):
with patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=1)), patch('playback.urllib.request.urlopen') as opening:
with self.assertRaises(playback.PlaybackError):
with playback.cached_source('unused', 'private', 8):
self.fail('low space accepted')
opening.assert_not_called()
def test_stale_buffers_are_removed_but_current_and_unrelated_files_remain(self):
with tempfile.TemporaryDirectory() as folder:
base=Path(folder)/'playback-tmp';base.mkdir()
old=base/'buffer-old';old.mkdir();(old/'source.mp4').write_bytes(b'old')
os.utime(old,(time.time()-7200,time.time()-7200))
fresh=base/'buffer-active';fresh.mkdir();(fresh/'source.mp4').write_bytes(b'active')
unrelated=base/'keep.txt';unrelated.write_text('keep')
with patch.dict('os.environ',{'VISION_DATA':folder}), patch('playback.shutil.disk_usage',return_value=SimpleNamespace(free=10**12)), patch('playback.urllib.request.urlopen',return_value=io.BytesIO(b'video')):
with playback.cached_source('http://127.0.0.1/source','private',8):
self.assertFalse(old.exists())
self.assertEqual((fresh/'source.mp4').read_bytes(),b'active')
self.assertEqual(unrelated.read_text(),'keep')
def test_local_converter_can_buffer_ahead_and_has_no_credentials(self):
with patch('playback.shutil.which', return_value='ffmpeg'), patch('playback.os.access', return_value=False):
cmd=playback.command('/tmp/private/source.mp4', '', 300)
self.assertNotIn('-readrate', cmd)
self.assertNotIn('-headers', cmd)
self.assertIn('libx264', cmd)
self.assertEqual(cmd[cmd.index('-threads')+1], '1')
def test_sessions_are_bounded_before_consuming_a_slot(self):
for duration in (0, 301, float('nan')):
with self.assertRaises(playback.PlaybackError):
with playback.stream('unused', 'private', duration):
self.fail('unbounded playback accepted')
self.assertTrue(playback.SLOTS.acquire(blocking=False))
playback.SLOTS.release()
if __name__ == '__main__':
unittest.main()
+11 -3
View File
@@ -13,9 +13,9 @@ test('microsecond recording starts round inward and never seek into the precedin
assert.equal(exact[0].start,Date.parse(raw)); assert.equal(exact[0].start,Date.parse(raw));
assert.deepEqual(Ranges.normalize([{start:raw,duration:0.0001}],date(0),date(1)),[]); assert.deepEqual(Ranges.normalize([{start:raw,duration:0.0001}],date(0),date(1)),[]);
}); });
test('long recording continues after each one-hour session',()=>{ test('long recording continues in bounded five-minute sessions',()=>{
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)}); const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(300)});
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null); assert.deepEqual(Ranges.next(ranges,date(300)),{start:+date(300),end:+date(600)});assert.equal(Ranges.next(ranges,date(8000)),null);
}); });
test('next segment skips the actual gap and does not replay the previous end',()=>{ test('next segment skips the actual gap and does not replay the previous end',()=>{
const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null); const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null);
@@ -56,6 +56,14 @@ test('late recording list cannot replace another camera or a logged-out view',as
vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c); vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c);
const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined); const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined);
}); });
test('resuming a long pause reopens the recorded position without a page refresh',()=>{
const c=ui({playbackRange:{start:10000,end:300000},playbackPausedAt:Date.now()-20000,playAt:(date,preserve)=>c.played={time:+date,preserve}});
c.$('#play-video').currentTime=45;
vm.runInContext(section('recording-ui.js','function pausePlayback(','function makeClip('),c);
c.resumePlayback();assert.deepEqual(c.played,{time:55000,preserve:true});assert.equal(c.playbackPausedAt,0);
c.played=null;c.playbackPausedAt=Date.now();c.resumePlayback();assert.equal(c.played,null);
c.playbackPausedAt=Date.now()-20000;c.loggedIn=false;c.resumePlayback();assert.equal(c.played,null);
});
test('download generation refuses gaps and invalidates old links first',()=>{ test('download generation refuses gaps and invalidates old links first',()=>{
const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error}); const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error});
vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c); vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c);
+6
View File
@@ -74,3 +74,9 @@ test('recording timeline paints with theme tokens and keeps range geometry', ()
]); ]);
assert.equal(ctx.strokeStyle, '#d3ccbc'); assert.equal(ctx.strokeStyle, '#d3ccbc');
}); });
test('mobile playback filters keep full-width readable controls',()=>{
const css=fs.readFileSync('frontend/src/bootstrap-polish.css','utf8');
assert.match(css,/#page-playback \.playback-controls \{ display: grid; grid-template-columns: minmax\(0, 1fr\)/);
assert.match(css,/#page-playback \.playback-controls input, #page-playback \.playback-controls select \{ width: 100%/);
});
+12
View File
@@ -7,6 +7,18 @@
.family-workspace.family-expanded{position:fixed;inset:0;z-index:8;} .family-workspace.family-expanded{position:fixed;inset:0;z-index:8;}
/* Keep scaled descendants and fractional fullscreen sizes out of the
scrollable overflow calculation. Reserve one gutter instead of alternating. */
.family-map-viewport{scrollbar-gutter:stable;overflow-anchor:none}
.family-map-space{min-width:0;min-height:0;overflow:clip}
.family-workspace:is(:fullscreen,.family-expanded){min-height:0;grid-template-rows:minmax(0,1fr)}
.family-workspace:is(:fullscreen,.family-expanded) .family-canvas-panel{min-height:0}
.family-workspace:is(:fullscreen,.family-expanded) .family-map-heading,
.family-workspace:is(:fullscreen,.family-expanded) .family-map-controls,
.family-workspace:is(:fullscreen,.family-expanded) .family-map-footer{flex-shrink:0}
.family-workspace.family-expanded{z-index:1100}
@media(max-width:1000px){.family-workspace:is(:fullscreen,.family-expanded) .family-canvas-panel{min-height:65vh}}
/* Versions remain readable on a small screen, including long family narratives. */ /* Versions remain readable on a small screen, including long family narratives. */
#history-dialog{width:min(1040px,94vw)} #history-dialog{width:min(1040px,94vw)}
#history-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;max-height:260px;overflow:auto;margin:16px 0} #history-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;max-height:260px;overflow:auto;margin:16px 0}
+21 -6
View File
@@ -10,9 +10,24 @@ class FamilyMap {
viewport.addEventListener('pointermove',e=>{if(!this.drag)return;viewport.scrollLeft=this.drag.left+this.drag.x-e.clientX;viewport.scrollTop=this.drag.top+this.drag.y-e.clientY;}); viewport.addEventListener('pointermove',e=>{if(!this.drag)return;viewport.scrollLeft=this.drag.left+this.drag.x-e.clientX;viewport.scrollTop=this.drag.top+this.drag.y-e.clientY;});
const release=()=>{this.drag=null;viewport.classList.remove('panning');}; const release=()=>{this.drag=null;viewport.classList.remove('panning');};
viewport.addEventListener('pointerup',release);viewport.addEventListener('pointercancel',release);viewport.addEventListener('lostpointercapture',release); viewport.addEventListener('pointerup',release);viewport.addEventListener('pointercancel',release);viewport.addEventListener('lostpointercapture',release);
this.resize=new ResizeObserver(()=>{if(this.autoFit)this.fit();else this.applyScale();});this.resize.observe(viewport); this.resizeFrame=0;this.resizeSize='';
this.resize=new ResizeObserver(()=>this.scheduleResize());this.resize.observe(viewport,{box:'border-box'});
}
reset(){cancelAnimationFrame(this.resizeFrame);this.resizeFrame=0;this.resizeSize='';this.key='';this.data=null;this.graph=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
viewportSize(){
const v=this.viewport,r=v.getBoundingClientRect();
// clientHeight rounds fractional pixels up; using it as a child height can
// create a scrollbar, remove it on the next fit, and repeat indefinitely.
return {width:Math.floor(Math.min(v.clientWidth,r.width)),height:Math.floor(Math.min(v.clientHeight,r.height))};
}
scheduleResize(){
if(this.resizeFrame)return;
this.resizeFrame=requestAnimationFrame(()=>{
this.resizeFrame=0;const {width,height}=this.viewportSize(),key=width+':'+height;
if(!width||!height||key===this.resizeSize)return;
this.resizeSize=key;if(this.autoFit)this.fit();else this.applyScale();
});
} }
reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
render(data) { render(data) {
this.data=data; this.data=data;
const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]); const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
@@ -46,16 +61,16 @@ class FamilyMap {
} }
applyScale() { applyScale() {
const v=this.viewport,g=this.graph,space=v.querySelector('.family-map-space'),surface=v.querySelector('.family-map-surface'); const v=this.viewport,g=this.graph,space=v.querySelector('.family-map-space'),surface=v.querySelector('.family-map-surface');
if(!g||!space||!v.clientWidth)return; const {width,height}=this.viewportSize();if(!g||!space||!width||!height)return;
const w=Math.max(v.clientWidth,g.width*this.scale+32),h=Math.max(v.clientHeight,g.height*this.scale+32); const w=Math.max(width,Math.ceil(g.width*this.scale+32)),h=Math.max(height,Math.ceil(g.height*this.scale+32));
space.style.width=w+'px';space.style.height=h+'px'; space.style.width=w+'px';space.style.height=h+'px';
this.left=(w-g.width*this.scale)/2;this.top=(h-g.height*this.scale)/2; this.left=(w-g.width*this.scale)/2;this.top=(h-g.height*this.scale)/2;
surface.style.transform=`translate(${this.left}px,${this.top}px) scale(${this.scale})`; surface.style.transform=`translate(${this.left}px,${this.top}px) scale(${this.scale})`;
$('#family-zoom-value').textContent=Math.round(this.scale*100)+'%'; $('#family-zoom-value').textContent=Math.round(this.scale*100)+'%';
} }
fit() { fit() {
const v=this.viewport,g=this.graph;if(!g||!v.clientWidth||!v.clientHeight)return; const v=this.viewport,g=this.graph,{width,height}=this.viewportSize();if(!g||!width||!height)return;
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(v.clientWidth-40)/g.width,(v.clientHeight-40)/g.height)); this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(width-40)/g.width,(height-40)/g.height));
this.applyScale();v.scrollTo(0,0); this.applyScale();v.scrollTo(0,0);
} }
zoom(factor) { zoom(factor) {
+1 -1
View File
@@ -13,7 +13,7 @@ const RecordingRanges=(()=>{
.filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start); .filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start);
const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result; const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result;
} }
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+3600000)}:null;} function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+300000)}:null;}
function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;} function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;}
function clip(ranges,start,end){ function clip(ranges,start,end){
const a=+start,b=+end; const a=+start,b=+end;
+14 -4
View File
@@ -1,5 +1,5 @@
'use strict'; 'use strict';
let playbackRange=null; let playbackRange=null,playbackPausedAt=0;
function playbackSourceHint(){renderRecordingStatus();if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';} function playbackSourceHint(){renderRecordingStatus();if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
function renderRecordingStatus(){ function renderRecordingStatus(){
@@ -15,7 +15,7 @@ function renderRecordingStatus(){
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;} function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
function resetPlayback(){ function resetPlayback(){
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load(); playbackGeneration++;playbackRange=null;playbackPausedAt=0;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
$('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset(); $('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset();
spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML=''; spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML='';
} }
@@ -42,8 +42,8 @@ function drawTimeline(){
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});} function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}
function playAt(date,preserveClip=false){ function playAt(date,preserveClip=false){
const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;} const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;}
playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{}); playbackPausedAt=0;playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每次最多一小时,结束后按续播选项处理';$('#play-empty').hidden=true; $('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每段最多 5 分钟,按续播选项连续播放';$('#play-empty').hidden=true;
$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false; $('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;
if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';} if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';}
} }
@@ -55,6 +55,15 @@ function continuePlayback(){
if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;} if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;}
playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`; playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`;
} }
function pausePlayback(){
const v=$('#play-video');if(playbackRange&&!v.ended)playbackPausedAt=Date.now();
}
function resumePlayback(){
const elapsed=playbackPausedAt?Date.now()-playbackPausedAt:0;playbackPausedAt=0;
if(elapsed<12000||!loggedIn||activePage!=='playback'||!playbackRange)return;
const time=playbackRange.start+$('#play-video').currentTime*1000;
if(time<playbackRange.end)playAt(new Date(time),true);
}
function makeClip(){ function makeClip(){
$('#clip-download').hidden=true; $('#clip-download').hidden=true;
try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value)); try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value));
@@ -67,6 +76,7 @@ $('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');retu
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));}; $('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';}; $('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback; $('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback;
$('#play-video').onpause=pausePlayback;$('#play-video').onplay=resumePlayback;
$('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();}; $('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();};
$('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();}; $('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();};
for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';}; for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-DKr_acag.js"></script> <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-CSPlB4s9.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-XTjkr4dE.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-B2d9n-y0.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>