Compare commits

...

1 Commits

Author SHA1 Message Date
Codex f7d3fea92f Buffer bounded recording segments for stable browser playback 2026-10-06 23:00:28 +08:00
12 changed files with 187 additions and 26 deletions
+10
View File
@@ -0,0 +1,10 @@
# v0.1.41 · 录像按需缓冲与连续回放
- 修复浏览器减慢读取时,长录像源 HTTP 连接超时而提前结束的问题。
- 回放以最多 5 分钟为一段,沿用自动续播和真实录像空档规则;原始片段下载仍可选择最多一小时。
- 每段录像在服务器私有临时目录缓冲,转换结束、失败或离开页面后清理;最多两个转换、每份缓存最大 256 MiB,受磁盘预留空间及下载时限保护。
- 转换进程仅访问临时文件,按播放速度读取并限制线程,不携带录像服务认证信息。
- 长时间暂停后从当前播放位置重新接入,无需刷新整个页面。
- 保留 v0.1.40 家谱全屏抖动、尺寸通知循环与侧栏遮挡修复。
30 天为录像最长保留时间。实际可保留时长仍受总录像容量上限、磁盘预留、码率和路数限制。
+1 -1
View File
@@ -1 +1 @@
0.1.40 0.1.41
+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.40", "version": "0.1.41",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
+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.40';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()
} }
+64 -6
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,9 +72,10 @@ 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',
# Recordings are finite HTTP downloads. Pacing their input like a # Recordings are finite HTTP downloads. Pacing their input like a
# live source stalls upstream writes and truncates long responses. # live source stalls upstream writes and truncates long responses.
@@ -43,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:
+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_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
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);
+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
+1 -1
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-BN-llATb.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-B2d9n-y0.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>