Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 12556bf10f Fix family map fullscreen jitter and long recording playback 2026-10-06 22:46:50 +08:00
13 changed files with 92 additions and 14 deletions
+10
View File
@@ -0,0 +1,10 @@
# v0.1.40 · 家谱画布稳定性与连续回放修复
- 修复全屏家谱因小数像素取整而反复产生滚动条、重算居中的抖动。
- 合并画布尺寸通知,尺寸不变时不重复适配;手动缩放与拖动位置保持。
- 修复浏览器不支持原生全屏时,兼容全屏画布被侧栏遮挡的问题。
- 保留手机端家谱画布及人物档案的独立滚动区域。
- 修复录像回放以直播速度读取历史 HTTP 文件,造成长片段上游写入超时而提前截断的问题;保留转换并发、线程和生命周期上限。
- 新增画布小数像素、尺寸通知合并、手动视角、隐藏和重置回归测试。
录像保留策略仍以当前平台设置为准:30 天为最长保留时间;录像容量上限和磁盘预留可提前触发暂停,不能据此保证存满 30 天。
+1 -1
View File
@@ -1 +1 @@
0.1.39
0.1.40
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "zhao-steward-ui",
"private": true,
"version": "0.1.39",
"version": "0.1.40",
"type": "module",
"scripts": {
"build": "vite build",
+1 -1
View File
@@ -15,6 +15,6 @@ export function enhance(root){
export async function loadControllers(){
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})
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.40';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
await window.ZhaoVue.boot()
}
+3 -1
View File
@@ -33,7 +33,9 @@ def command(source, authorization, duration):
'-threads', '1', '-filter_threads', '1'] + acceleration + [
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
'-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 + [
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
+2
View File
@@ -258,6 +258,8 @@ class WebTests(unittest.TestCase):
self.assertIn('h264_vaapi', command)
self.assertIn('pipe:1', command)
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):
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\}/);
});
+12
View File
@@ -7,6 +7,18 @@
.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. */
#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}
+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;});
const release=()=>{this.drag=null;viewport.classList.remove('panning');};
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) {
this.data=data;
const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
@@ -46,16 +61,16 @@ class FamilyMap {
}
applyScale() {
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 w=Math.max(v.clientWidth,g.width*this.scale+32),h=Math.max(v.clientHeight,g.height*this.scale+32);
const {width,height}=this.viewportSize();if(!g||!space||!width||!height)return;
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';
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})`;
$('#family-zoom-value').textContent=Math.round(this.scale*100)+'%';
}
fit() {
const v=this.viewport,g=this.graph;if(!g||!v.clientWidth||!v.clientHeight)return;
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(v.clientWidth-40)/g.width,(v.clientHeight-40)/g.height));
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,(width-40)/g.width,(height-40)/g.height));
this.applyScale();v.scrollTo(0,0);
}
zoom(factor) {
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-CWMAn71z.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-B5ZdIQcb.css">
<!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-BN-llATb.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-B2d9n-y0.css">
</head><body><div id="app"></div></body></html>