Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7d3fea92f | |||
| 12556bf10f |
@@ -0,0 +1,10 @@
|
|||||||
|
# v0.1.40 · 家谱画布稳定性与连续回放修复
|
||||||
|
|
||||||
|
- 修复全屏家谱因小数像素取整而反复产生滚动条、重算居中的抖动。
|
||||||
|
- 合并画布尺寸通知,尺寸不变时不重复适配;手动缩放与拖动位置保持。
|
||||||
|
- 修复浏览器不支持原生全屏时,兼容全屏画布被侧栏遮挡的问题。
|
||||||
|
- 保留手机端家谱画布及人物档案的独立滚动区域。
|
||||||
|
- 修复录像回放以直播速度读取历史 HTTP 文件,造成长片段上游写入超时而提前截断的问题;保留转换并发、线程和生命周期上限。
|
||||||
|
- 新增画布小数像素、尺寸通知合并、手动视角、隐藏和重置回归测试。
|
||||||
|
|
||||||
|
录像保留策略仍以当前平台设置为准:30 天为最长保留时间;录像容量上限和磁盘预留可提前触发暂停,不能据此保证存满 30 天。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# v0.1.41 · 录像按需缓冲与连续回放
|
||||||
|
|
||||||
|
- 修复浏览器减慢读取时,长录像源 HTTP 连接超时而提前结束的问题。
|
||||||
|
- 回放以最多 5 分钟为一段,沿用自动续播和真实录像空档规则;原始片段下载仍可选择最多一小时。
|
||||||
|
- 每段录像在服务器私有临时目录缓冲,转换结束、失败或离开页面后清理;最多两个转换、每份缓存最大 256 MiB,受磁盘预留空间及下载时限保护。
|
||||||
|
- 转换进程仅访问临时文件,按播放速度读取并限制线程,不携带录像服务认证信息。
|
||||||
|
- 长时间暂停后从当前播放位置重新接入,无需刷新整个页面。
|
||||||
|
- 保留 v0.1.40 家谱全屏抖动、尺寸通知循环与侧栏遮挡修复。
|
||||||
|
|
||||||
|
30 天为录像最长保留时间。实际可保留时长仍受总录像容量上限、磁盘预留、码率和路数限制。
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "zhao-steward-ui",
|
"name": "zhao-steward-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.39",
|
"version": "0.1.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
|||||||
@@ -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.39';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.41';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
@@ -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 ['-readrate', '1'])
|
||||||
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:
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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\}/);
|
||||||
|
});
|
||||||
@@ -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_is_paced_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.assertIn('-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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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-CWMAn71z.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-F3a-FbDz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-B5ZdIQcb.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user