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
- 点击卡片切换 use/discard,手动覆盖阈值判定;手动标记角标 + 重置按钮 - 导出 PDF:后台 job,逐张把帧 JPEG 以 DCTDecode 直接嵌入、边读边写到磁盘, 内存峰值只一张帧,防大视频 OOM;前端轮询进度条 - 下载走流式(ReaderStream),不把整份 PDF 读进内存;?c= query 触发下载 - 手撸极简 PDF writer(无新 PDF 依赖),只扫 JPEG 头取宽高分量数
466 lines
24 KiB
HTML
466 lines
24 KiB
HTML
<!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>
|