Files
cube/apps/video2slides/frontend/index.html
T
Fam Zheng b36e30ecbf
deploy articulate / build-and-deploy (push) Successful in 58s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 59s
deploy llm-proxy / build-and-deploy (push) Successful in 1m49s
deploy music / build-and-deploy (push) Successful in 2m6s
deploy notes / build-and-deploy (push) Successful in 1m42s
deploy simpleasm / build-and-deploy (push) Successful in 1m18s
deploy webgl / build-and-deploy (push) Successful in 1m5s
deploy video2slides / build-and-deploy (push) Successful in 2m3s
deploy werewolf / build-and-deploy (push) Successful in 1m5s
deploy write / build-and-deploy (push) Successful in 1m24s
video2slides: 加 PDF 导出(服务端流式)+ 点击切换帧 保留/弃用
- 点击卡片切换 use/discard,手动覆盖阈值判定;手动标记角标 + 重置按钮
- 导出 PDF:后台 job,逐张把帧 JPEG 以 DCTDecode 直接嵌入、边读边写到磁盘,
  内存峰值只一张帧,防大视频 OOM;前端轮询进度条
- 下载走流式(ReaderStream),不把整份 PDF 读进内存;?c= query 触发下载
- 手撸极简 PDF writer(无新 PDF 依赖),只扫 JPEG 头取宽高分量数
2026-06-14 21:58:27 +01:00

466 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>video2slides — 长视频抽帧挑幻灯片</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --line:#2a303c;
--fg:#e8ebf0; --mut:#8b94a3; --accent:#f2b50c; --accent2:#3b82f6;
--keep:#f2b50c; --ok:#22c55e; --err:#ef4444;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header.appbar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);flex:0 0 auto;z-index:50}
header.appbar h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.3px}
header.appbar .tag{color:var(--mut);font-size:12px}
header.appbar .spacer{flex:1}
.layout{display:flex;flex:1;min-height:0}
aside{width:240px;flex:0 0 240px;background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
aside .side-head{padding:10px 12px;border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center}
aside .vlist{overflow:auto;flex:1;padding:6px}
.vitem{padding:8px 10px;border-radius:8px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
.vitem:hover{background:var(--panel2)}
.vitem.active{background:var(--panel2);border-color:var(--accent)}
.vitem .vname{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vitem .vmeta{font-size:11px;color:var(--mut);display:flex;gap:6px;align-items:center;margin-top:2px}
.badge{font-size:10px;padding:1px 6px;border-radius:99px;background:#333}
.badge.done{background:rgba(34,197,94,.18);color:var(--ok)}
.badge.processing,.badge.extracting,.badge.queued{background:rgba(59,130,246,.18);color:var(--accent2)}
.badge.uploaded{background:rgba(139,148,163,.18);color:var(--mut)}
.badge.error{background:rgba(239,68,68,.18);color:var(--err)}
.vdel{margin-left:auto;color:var(--mut);cursor:pointer;font-size:14px;opacity:0;padding:0 4px}
.vitem:hover .vdel{opacity:1}
.vdel:hover{color:var(--err)}
main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0;position:relative}
/* sticky toolbar 常驻顶部 */
.toolbar{position:sticky;top:0;z-index:40;background:var(--panel2);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:18px;flex-wrap:wrap;box-shadow:0 2px 8px rgba(0,0,0,.3)}
.toolbar .grp{display:flex;align-items:center;gap:8px}
.toolbar label{color:var(--mut);font-size:12px;white-space:nowrap}
.toolbar input[type=range]{width:200px;accent-color:var(--accent)}
.thval{font-variant-numeric:tabular-nums;min-width:42px;text-align:right;color:var(--accent);font-weight:600}
.stat{font-size:12px;color:var(--mut)}
.stat b{color:var(--keep)}
.content{flex:1;overflow:auto;padding:16px;min-height:0}
/* 网格 */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
.card{position:relative;background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s;cursor:pointer}
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000;transition:filter .15s}
.card .cap{display:flex;justify-content:space-between;padding:5px 8px;font-size:11px;color:var(--mut)}
.card .cap .d{font-variant-numeric:tabular-nums}
.card.keep{border-color:var(--keep)}
.card.dim{opacity:.45}
.card.dim img{filter:grayscale(1)}
.card.dim:hover{opacity:.92}
.card.dim:hover img{filter:grayscale(0)}
/* 手动 use/discard 标记 —— 放图片上层、保持彩色(不被 dim 灰化),不挡点击 */
.card .pin{position:absolute;top:6px;left:6px;font-size:10px;line-height:1;padding:3px 6px;border-radius:6px;font-weight:700;display:none;z-index:2;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.card.pinned .pin{display:block}
.card .pin.use{background:var(--keep);color:#1a1500}
.card .pin.discard{background:var(--err);color:#fff}
.card.pinned{box-shadow:0 0 0 2px rgba(255,255,255,.12) inset}
/* 悬停大图预览 */
#preview{position:fixed;z-index:100;pointer-events:none;display:none;border:2px solid var(--accent);border-radius:10px;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.6);background:#000}
#preview img{display:block;max-width:560px;max-height:360px}
#preview .pcap{position:absolute;left:0;bottom:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));color:#fff;font-size:12px;padding:14px 10px 6px}
/* 状态屏 */
.pane{flex:1;display:flex;align-items:center;justify-content:center;padding:24px}
.pane .box{max-width:520px;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:28px}
.pane h2{margin:0 0 6px;font-size:18px}
.pane p{color:var(--mut);margin:0 0 18px}
.drop{border:2px dashed var(--line);border-radius:12px;padding:34px;text-align:center;color:var(--mut);cursor:pointer;transition:.15s}
.drop.over{border-color:var(--accent);background:rgba(242,181,12,.06);color:var(--fg)}
button{font:inherit;border:0;border-radius:8px;padding:8px 14px;background:var(--accent);color:#1a1500;font-weight:600;cursor:pointer}
button.ghost{background:var(--panel2);color:var(--fg);border:1px solid var(--line);font-weight:500}
button:disabled{opacity:.5;cursor:not-allowed}
button.sm{padding:5px 10px;font-size:12px}
input[type=number]{font:inherit;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;width:90px}
.progress{height:10px;background:var(--bg);border-radius:99px;overflow:hidden;border:1px solid var(--line)}
.progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .3s}
.pmsg{font-size:12px;color:var(--mut);margin-top:8px;text-align:center}
.row{display:flex;gap:10px;align-items:center}
.err{color:var(--err);font-size:12px}
.pager{display:flex;gap:6px;align-items:center}
#expmask{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center}
#expmask.on{display:flex}
#expmask .box2{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:24px 30px;text-align:center}
#ebar{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .15s}
.pager .pg{padding:4px 9px;background:var(--panel);border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:12px}
.pager .pg.cur{background:var(--accent);color:#1a1500;border-color:var(--accent)}
.switch{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
</style>
</head>
<body>
<header class="appbar">
<h1>🎞️ video2slides</h1>
<span class="tag">长视频 → 抽帧 → 挑关键画面/幻灯片</span>
<span class="spacer"></span>
<button id="btnUpload" class="sm"> 上传视频</button>
<input type="file" id="fileInput" accept="video/*" hidden />
</header>
<div class="layout">
<aside>
<div class="side-head"><b style="font-size:13px">我的视频</b><span class="tag" style="margin-left:auto;font-size:11px" id="clientTag"></span></div>
<div class="vlist" id="vlist"></div>
</aside>
<main id="main"><!-- 动态 --></main>
</div>
<div id="preview"><img id="previewImg" alt="" /><div class="pcap" id="previewCap"></div></div>
<div id="expmask"><div class="box2">
<div class="progress" style="width:280px"><i id="ebar"></i></div>
<div class="pmsg" id="emsg">导出中…</div>
</div></div>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
// ---- 每浏览器身份:localStorage ----
const CK='v2s_client';
let CLIENT=localStorage.getItem(CK);
if(!CLIENT){CLIENT=(crypto.randomUUID?crypto.randomUUID():'c'+Date.now()+Math.random().toString(16).slice(2)).replace(/-/g,'');localStorage.setItem(CK,CLIENT);}
$('#clientTag').textContent=CLIENT.slice(0,6);
const H={'X-Client-Id':CLIENT};
async function api(path,opts={}){
const r=await fetch(path,{...opts,headers:{...H,...(opts.headers||{})}});
if(!r.ok){let m='';try{m=(await r.json()).detail}catch{}throw new Error(m||r.status);}
const ct=r.headers.get('content-type')||'';
return ct.includes('json')?r.json():r;
}
function imgUrl(vid,kind,idx){return `/api/videos/${vid}/${kind}/${idx}?c=${encodeURIComponent(CLIENT)}`;}
const fmtDur=s=>{s=Math.round(s||0);const m=Math.floor(s/60),x=s%60;return `${m}:${String(x).padStart(2,'0')}`;};
const fmtSize=b=>b>1e9?(b/1e9).toFixed(1)+'G':b>1e6?(b/1e6).toFixed(1)+'M':(b/1e3|0)+'K';
const fmtT=s=>{s=Math.round(s);const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;const p=n=>String(n).padStart(2,'0');return h?`${h}:${p(m)}:${p(x)}`:`${m}:${p(x)}`;};
// ---- 全局状态 ----
let videos=[];
let cur=null; // 当前打开的 video 对象 (来自 frames 接口)
let curId=null;
let threshold=10, page=0, pageSize=48, onlyKept=false;
let overrides={}; // idx -> true(手动保留) / false(手动弃用),覆盖阈值判定
let pollTimer=null;
// ====================== 侧栏 ======================
async function loadVideos(){
videos=await api('/api/videos');
renderSidebar();
}
function renderSidebar(){
const box=$('#vlist');box.innerHTML='';
if(!videos.length){const e=el('div');e.style.cssText='color:var(--mut);font-size:12px;padding:14px;text-align:center';e.textContent='还没有视频,点右上角上传';box.appendChild(e);return;}
for(const v of videos){
const it=el('div','vitem'+(v.video_id===curId?' active':''));
const nm=el('div','vname');nm.textContent=v.name;nm.title=v.name;
const mt=el('div','vmeta');
const bd=el('span','badge '+v.status);bd.textContent=({done:'已分析',processing:'分析中',extracting:'抽帧中',queued:'排队',uploaded:'待分析',error:'出错'})[v.status]||v.status;
mt.appendChild(bd);
const info=el('span');info.textContent=fmtDur(v.duration)+(v.frame_count?` · ${v.frame_count}`:'');mt.appendChild(info);
const del=el('span','vdel');del.textContent='✕';del.title='删除';
del.onclick=async(ev)=>{ev.stopPropagation();if(!confirm('删除「'+v.name+'」?'))return;await api('/api/videos/'+v.video_id,{method:'DELETE'});if(curId===v.video_id){curId=null;cur=null;}await loadVideos();render();};
it.append(nm,mt,del);
it.onclick=()=>openVideo(v.video_id);
box.appendChild(it);
}
}
// ====================== 打开视频 / 路由渲染 ======================
async function openVideo(vid){
stopPoll();
curId=vid;page=0;overrides={};
const v=videos.find(x=>x.video_id===vid);
renderSidebar();
if(v && (v.status==='processing'||v.status==='extracting'||v.status==='queued')){
cur=await api(`/api/videos/${vid}/frames`).catch(()=>null);
render();startPoll();return;
}
cur=await api(`/api/videos/${vid}/frames`);
render();
}
function render(){
const m=$('#main');m.innerHTML='';
if(!curId||!cur){m.appendChild(emptyPane());return;}
if(cur.status==='uploaded'||cur.status==='error') {m.appendChild(setupPane());return;}
if(cur.status!=='done'){m.appendChild(progressPane());return;}
// done
m.appendChild(buildToolbar());
const content=el('div','content');content.id='content';m.appendChild(content);
renderGrid();
}
// ---- 空状态 ----
function emptyPane(){
const p=el('div','pane'),b=el('div','box');
b.innerHTML='<h2>从演讲视频里挑幻灯片</h2><p>上传一个长视频,每隔 N 秒抽一帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化。</p>';
const drop=el('div','drop');drop.innerHTML='把视频拖到这里,或 <b>点击选择</b>';
wireDrop(drop);b.appendChild(drop);p.appendChild(b);return p;
}
// ---- 上传中 / 分析参数设置 ----
function setupPane(){
const p=el('div','pane'),b=el('div','box');
const err=cur.status==='error';
b.innerHTML=`<h2>${err?'上次分析出错了':'设置分析参数'}</h2>
<p>${cur.name||''} · 时长 ${fmtDur(cur.duration)}</p>`;
const row=el('div','row');
row.innerHTML=`<label style="color:var(--mut)">每隔</label>`;
const inp=el('input');inp.type='number';inp.min='0.2';inp.max='600';inp.step='0.5';inp.value=cur.interval||8;
const unit=el('span');unit.textContent='秒抽一帧';unit.style.color='var(--mut)';
const go=el('button');go.textContent=err?'重新分析':'开始分析';
go.onclick=()=>startAnalyze(curId,parseFloat(inp.value));
row.append(inp,unit,go);b.appendChild(row);
const hint=el('p');hint.style.cssText='margin-top:14px;font-size:12px';hint.textContent='间隔越小帧越多、越慢但越细。演讲翻页慢,8 秒通常够。';
b.appendChild(hint);
p.appendChild(b);return p;
}
// ---- 分析进度 ----
function progressPane(){
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>正在分析…</h2><p>${cur.name||''}</p>
<div class="progress"><i id="pbar"></i></div><div class="pmsg" id="pmsg">准备中…</div>`;
p.appendChild(b);return p;
}
// ====================== Toolbar + 网格 ======================
// 阈值给的是默认判定;overrides 是手动例外(点击切换),手动优先。
function autoKeep(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
function keptOf(f){return (f.idx in overrides)?overrides[f.idx]:autoKeep(f);}
function shown(){return onlyKept?cur.frames.filter(keptOf):cur.frames;}
// 点击卡片:切换 保留/弃用。若切回与阈值默认一致,则清掉该例外(回归自动)。
function toggleFrame(f){
const next=!keptOf(f);
if(next===autoKeep(f)) delete overrides[f.idx]; else overrides[f.idx]=next;
updateStats();
if(onlyKept){renderGrid();} // 弃用后该帧应从「仅保留」视图消失
else { const card=document.querySelector(`.card[data-idx="${f.idx}"]`); if(card)paintCard(card,f); }
}
// 统一刷新一张卡片的 保留/弃用/手动标记 外观
function paintCard(card,f){
const k=keptOf(f), pinned=(f.idx in overrides);
card.classList.toggle('keep',k);
card.classList.toggle('dim',!k);
card.classList.toggle('pinned',pinned);
let pin=card.querySelector('.pin');
if(pin){pin.className='pin '+(k?'use':'discard');pin.textContent=k?'✓ 手动保留':'✕ 手动弃用';}
}
function buildToolbar(){
const t=el('div','toolbar');
// 阈值
const g1=el('div','grp');
g1.innerHTML=`<label>差异阈值</label>`;
const sl=el('input');sl.type='range';sl.min='0';sl.max='100';sl.step='0.5';sl.value=threshold;
const val=el('span','thval');val.textContent=threshold;
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();if(onlyKept){page=0;renderGrid();}};
g1.append(sl,val);
// 仅保留
const g2=el('label','switch');
const cb=el('input');cb.type='checkbox';cb.checked=onlyKept;
cb.onchange=()=>{onlyKept=cb.checked;page=0;renderGrid();};
g2.append(cb,document.createTextNode('仅显示保留帧'));
// 统计
const g3=el('div','grp');g3.innerHTML=`<span class="stat" id="stat"></span>`;
// 导出 / 重置手动 / 间隔 / 重分析
const g4=el('div','grp');g4.style.marginLeft='auto';
const exp=el('button','sm');exp.textContent='⬇ 导出 PDF';exp.onclick=exportPdf;
const rs=el('button','ghost sm');rs.textContent='重置手动';rs.title='清除所有手动 保留/弃用,回到纯阈值判定';
rs.onclick=()=>{overrides={};updateStats();onlyKept?renderGrid():applyClasses();};
g4.append(exp,rs,el('span','stat'));g4.lastChild.textContent=`间隔 ${cur.interval}s`;
const re=el('button','ghost sm');re.textContent='重新分析';re.onclick=()=>{cur.status='uploaded';render();};
g4.appendChild(re);
// 分页
const g5=el('div','grp pager');g5.id='pager';
t.append(g1,g2,g3,g4,g5);
setTimeout(()=>{updateStats();renderPager();},0);
return t;
}
function updateStats(){
const total=cur.frames.length;
const kept=cur.frames.filter(keptOf).length;
const man=Object.keys(overrides).length;
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total}`+(man?` <span style="opacity:.7">· 手动 ${man}</span>`:'');
}
function renderGrid(){
const c=$('#content');if(!c)return;c.innerHTML='';
const list=shown();
const pages=Math.max(1,Math.ceil(list.length/pageSize));
if(page>=pages)page=pages-1;
const slice=list.slice(page*pageSize,(page+1)*pageSize);
const grid=el('div','grid');
for(const f of slice){
const card=el('div','card');
card.dataset.idx=f.idx;card.title='点击切换 保留/弃用';
const pin=el('span','pin');
const img=el('img');img.loading='lazy';img.src=imgUrl(curId,'thumb',f.idx);img.alt='';
const cap=el('div','cap');
cap.innerHTML=`<span>${fmtT(f.t)}</span><span class="d">${f.diff===null||f.diff===undefined?'—':'Δ'+f.diff}</span>`;
card.append(pin,img,cap);
paintCard(card,f);
card.onclick=()=>toggleFrame(f);
bindHover(card,f);
grid.appendChild(card);
}
c.appendChild(grid);
if(!slice.length){const e=el('div');e.style.cssText='color:var(--mut);text-align:center;padding:40px';e.textContent='没有符合的帧';c.appendChild(e);}
renderPager();
}
function renderPager(){
const box=$('#pager');if(!box)return;box.innerHTML='';
const list=shown();const pages=Math.max(1,Math.ceil(list.length/pageSize));
if(pages<=1)return;
const mk=(label,p,cls='')=>{const b=el('span','pg '+cls);b.textContent=label;b.onclick=()=>{page=p;renderGrid();$('#content').scrollTop=0;};return b;};
box.appendChild(mk('',Math.max(0,page-1)));
const win=[];for(let i=0;i<pages;i++){if(i<2||i>=pages-2||Math.abs(i-page)<=1)win.push(i);}
let last=-1;
for(const i of win){if(i-last>1){const d=el('span');d.textContent='…';d.style.color='var(--mut)';box.appendChild(d);}box.appendChild(mk(i+1,i,i===page?'cur':''));last=i;}
box.appendChild(mk('',Math.min(pages-1,page+1)));
}
// 阈值变化时只切 class,不重建 DOM(性能)
function applyClasses(){
document.querySelectorAll('.card').forEach(card=>{
const idx=+card.dataset.idx;const f=cur.frames.find(x=>x.idx===idx);if(!f)return;
paintCard(card,f);
});
}
// ====================== 悬停大图 ======================
const pv=$('#preview'),pvImg=$('#previewImg'),pvCap=$('#previewCap');
let hoverIdx=null;
function bindHover(card,f){
card.onmouseenter=()=>{
hoverIdx=f.idx;
pvImg.onload=()=>{if(hoverIdx!==f.idx)return;positionPreview(card);pv.style.display='block';};
pvImg.src=imgUrl(curId,'frame',f.idx);
pvCap.textContent=`${fmtT(f.t)} · Δ${f.diff===null||f.diff===undefined?'起始帧':f.diff}`;
if(pvImg.complete&&pvImg.naturalWidth){positionPreview(card);pv.style.display='block';}
};
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';};
}
function positionPreview(card){
const r=card.getBoundingClientRect();
const pw=pv.offsetWidth||560,ph=pv.offsetHeight||360;
let x=r.right+12;if(x+pw>innerWidth)x=r.left-pw-12;if(x<8)x=Math.max(8,(innerWidth-pw)/2);
let y=r.top+r.height/2-ph/2;y=Math.min(Math.max(8,y),innerHeight-ph-8);
pv.style.left=x+'px';pv.style.top=y+'px';
}
// ====================== 上传 ======================
function wireDrop(drop){
drop.onclick=()=>$('#fileInput').click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('over');};
drop.ondragleave=()=>drop.classList.remove('over');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('over');if(e.dataTransfer.files[0])upload(e.dataTransfer.files[0]);};
}
$('#btnUpload').onclick=()=>$('#fileInput').click();
$('#fileInput').onchange=e=>{if(e.target.files[0])upload(e.target.files[0]);e.target.value='';};
function upload(file){
const m=$('#main');m.innerHTML='';
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>上传中…</h2><p>${file.name} · ${fmtSize(file.size)}</p><div class="progress"><i id="ubar"></i></div><div class="pmsg" id="umsg">0%</div>`;
p.appendChild(b);m.appendChild(p);curId=null;cur=null;renderSidebar();
const fd=new FormData();fd.append('file',file);
const xhr=new XMLHttpRequest();
xhr.open('POST','/api/videos');
xhr.setRequestHeader('X-Client-Id',CLIENT);
xhr.upload.onprogress=e=>{if(e.lengthComputable){const pc=Math.round(e.loaded/e.total*100);$('#ubar').style.width=pc+'%';$('#umsg').textContent=pc+'%'+(pc>=100?' · 服务器解析中…':'');}};
xhr.onload=async()=>{
if(xhr.status>=200&&xhr.status<300){
const v=JSON.parse(xhr.responseText);
await loadVideos();await openVideo(v.video_id);
}else{let d='上传失败';try{d=JSON.parse(xhr.responseText).detail}catch{}
b.innerHTML=`<h2>上传失败</h2><p class="err">${d}</p>`;
const again=el('button');again.textContent='重试';again.onclick=()=>$('#fileInput').click();b.appendChild(again);
}
};
xhr.onerror=()=>{b.innerHTML='<h2>上传失败</h2><p class="err">网络错误</p>';};
xhr.send(fd);
}
// ====================== 分析 + 轮询 ======================
async function startAnalyze(vid,interval){
if(!(interval>=0.2&&interval<=600)){alert('间隔需在 0.2~600 秒');return;}
await api(`/api/videos/${vid}/analyze`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interval})});
cur.status='processing';cur.interval=interval;render();
await loadVideos();renderSidebar();
startPoll();
}
function startPoll(){
stopPoll();
pollTimer=setInterval(async()=>{
let j;try{j=await api(`/api/videos/${curId}/job`);}catch{return;}
const bar=$('#pbar'),msg=$('#pmsg');
if(bar)bar.style.width=Math.round((j.progress||0)*100)+'%';
if(msg)msg.textContent=j.message||j.status;
if(j.status==='done'){stopPoll();cur=await api(`/api/videos/${curId}/frames`);await loadVideos();render();}
else if(j.status==='error'){stopPoll();cur.status='error';await loadVideos();render();}
},600);
}
function stopPoll(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}}
// ====================== 导出 PDF(服务端生成 + 进度条)======================
let exporting=false;
async function exportPdf(){
if(exporting)return;
const idxs=cur.frames.filter(keptOf).map(f=>f.idx);
if(!idxs.length){alert('没有要导出的帧 —— 调低阈值或手动保留几帧再试');return;}
if(idxs.length>1000 && !confirm(`要导出 ${idxs.length} 页,PDF 会比较大、耗时长,继续?`))return;
exporting=true;
const mask=$('#expmask'),bar=$('#ebar'),msg=$('#emsg');
bar.style.width='0%';msg.textContent=`启动导出(${idxs.length} 帧)…`;mask.classList.add('on');
const vid=curId;
try{
await api(`/api/videos/${vid}/pdf`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({frames:idxs})});
// 轮询进度
await new Promise((resolve,reject)=>{
const t=setInterval(async()=>{
let j;try{j=await api(`/api/videos/${vid}/pdf/status`);}catch{return;}
bar.style.width=Math.round((j.progress||0)*100)+'%';
msg.textContent=j.message||j.status;
if(j.status==='done'){clearInterval(t);resolve();}
else if(j.status==='error'){clearInterval(t);reject(new Error(j.error||'导出出错'));}
},400);
});
// 触发下载(GET + ?c=<a download> 带不了头)
const a=el('a');a.href=`/api/videos/${vid}/pdf/download?c=${encodeURIComponent(CLIENT)}`;a.download='';
document.body.appendChild(a);a.click();a.remove();
msg.textContent='完成,开始下载';
await new Promise(r=>setTimeout(r,500));
}catch(e){alert(e.message||'导出失败');}
finally{exporting=false;mask.classList.remove('on');}
}
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
// ====================== 启动 ======================
(async()=>{await loadVideos();render();})();
</script>
</body>
</html>