Files
Fam Zheng a0253e118f
deploy articulate / build-and-deploy (push) Successful in 1m1s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 55s
deploy llm-proxy / build-and-deploy (push) Successful in 1m44s
deploy music / build-and-deploy (push) Successful in 2m14s
deploy notes / build-and-deploy (push) Successful in 1m40s
deploy simpleasm / build-and-deploy (push) Successful in 1m17s
deploy video2slides / build-and-deploy (push) Successful in 39s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
deploy webgl / build-and-deploy (push) Successful in 1m17s
deploy write / build-and-deploy (push) Successful in 1m13s
video2slides: 重构为纯客户端 app(浏览器抽帧 + IndexedDB),后端归零
- 不再上传视频:<video>+canvas 原生解码按时间戳 seek 抽帧,逐帧 256px 灰度
  MAE 算差异,缩略图(320)+大图(1280) 随抽随写 IndexedDB,带进度条+ETA
- 阈值/手动 保留弃用/缩放偏好 持久化到 IndexedDB,刷新仍在
- PDF 导出回到客户端 jsPDF,保留帧逐张 base64 嵌入、单帧处理防 OOM
- 后端删光业务逻辑(core.rs/handlers.rs),main.rs 缩成 cube_core::base 静态服务
- 不再需要 ffmpeg → Dockerfile 回归 FROM scratch;k8s 去掉 hostPath 卷、降资源
- 真浏览器(Playwright)验证:抽帧/差异/阈值/持久化/导出 全通过
2026-06-14 22:25:20 +01:00

466 lines
26 KiB
HTML
Raw Permalink 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; --thumbw:180px;
}
*{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:rgba(34,197,94,.18);color:var(--ok)}
.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}
.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]{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(var(--thumbw,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)}
.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:540px;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 .2s}
.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}
.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}
#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}
</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">本机</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 src="jspdf.umd.min.js"></script>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
const genId = ()=>(crypto.randomUUID?crypto.randomUUID():'v'+Date.now()+Math.random().toString(16).slice(2)).replace(/-/g,'').slice(0,16);
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)}`;};
const fmtEta=s=>{if(!isFinite(s)||s<0)return '…';s=Math.round(s);return s>=60?`${Math.floor(s/60)}${s%60}`:`${s}`;};
// ====================== IndexedDB ======================
// videos: {id,name,size,duration,interval,frames:[{idx,t,diff,pw,ph}],threshold,overrides,createdAt}
// frames: {key:`${id}|${idx}|thumb|preview`, vid:id, blob}
let _db;
function idb(){return _db?Promise.resolve(_db):new Promise((res,rej)=>{
const r=indexedDB.open('video2slides',1);
r.onupgradeneeded=()=>{const db=r.result;
if(!db.objectStoreNames.contains('videos'))db.createObjectStore('videos',{keyPath:'id'});
if(!db.objectStoreNames.contains('frames')){const s=db.createObjectStore('frames',{keyPath:'key'});s.createIndex('vid','vid');}
};
r.onsuccess=()=>{_db=r.result;res(_db);};
r.onerror=()=>rej(r.error);
});}
const store=(name,mode)=>idb().then(db=>db.transaction(name,mode).objectStore(name));
const req2p=r=>new Promise((res,rej)=>{r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});
const idbPut=(name,val)=>store(name,'readwrite').then(s=>req2p(s.put(val)));
const idbGet=(name,key)=>store(name,'readonly').then(s=>req2p(s.get(key)));
const idbGetAll=name=>store(name,'readonly').then(s=>req2p(s.getAll()));
async function idbDeleteVideo(id){
await store('videos','readwrite').then(s=>req2p(s.delete(id)));
const s=await store('frames','readwrite');
await new Promise((res,rej)=>{
const c=s.index('vid').openKeyCursor(IDBKeyRange.only(id));
c.onsuccess=()=>{const cur=c.result;if(cur){s.delete(cur.primaryKey);cur.continue();}else res();};
c.onerror=()=>rej(c.error);
});
}
// ====================== 全局状态 ======================
let videos=[];
let cur=null, curId=null;
let threshold=10, page=0, pageSize=48, onlyKept=false, overrides={};
let thumbW=+(localStorage.getItem('v2s_thumbw')||180);
let pendingFile=null;
let gridUrls=[]; // 当前网格的 object URL,重渲染时回收
function applyThumbW(){document.documentElement.style.setProperty('--thumbw',thumbW+'px');}
applyThumbW();
// ====================== 侧栏 ======================
async function loadVideos(){
videos=(await idbGetAll('videos')).sort((a,b)=>b.createdAt-a.createdAt);
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.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');bd.textContent=`${v.frames.length}`;mt.appendChild(bd);
const info=el('span');info.textContent=`${fmtDur(v.duration)} · 每${v.interval}s`;mt.appendChild(info);
const del=el('span','vdel');del.textContent='✕';del.title='删除';
del.onclick=async(ev)=>{ev.stopPropagation();if(!confirm('删除「'+v.name+'」?(仅删本机缓存)'))return;await idbDeleteVideo(v.id);if(curId===v.id){curId=null;cur=null;}await loadVideos();render();};
it.append(nm,mt,del);
it.onclick=()=>openVideo(v.id);
box.appendChild(it);
}
}
// ====================== 打开 / 渲染 ======================
async function openVideo(id){
curId=id;page=0;
cur=videos.find(v=>v.id===id)||await idbGet('videos',id);
threshold=cur.threshold??10; overrides=cur.overrides||{};
renderSidebar();render();
}
function render(){
const m=$('#main');revokeGridUrls();m.innerHTML='';
if(!curId||!cur){m.appendChild(emptyPane());return;}
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 秒抽一帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化。<br><br>视频<b>不会上传</b>,全程在你浏览器里跑;结果缓存在本机(IndexedDB),换设备/清缓存即清空。</p>';
const drop=el('div','drop');drop.innerHTML='把视频拖到这里,或 <b>点击选择</b>';
wireDrop(drop);b.appendChild(drop);p.appendChild(b);return p;
}
// ====================== 选择文件 → 设置 → 抽帧 ======================
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])pickFile(e.dataTransfer.files[0]);};
}
$('#btnUpload').onclick=()=>$('#fileInput').click();
$('#fileInput').onchange=e=>{if(e.target.files[0])pickFile(e.target.files[0]);e.target.value='';};
function pickFile(file){
pendingFile=file;curId=null;cur=null;renderSidebar();
const m=$('#main');revokeGridUrls();m.innerHTML='';
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>设置抽帧参数</h2><p>${file.name} · ${fmtSize(file.size)}</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=8;
const unit=el('span');unit.textContent='秒抽一帧';unit.style.color='var(--mut)';
const go=el('button');go.textContent='开始处理';
go.onclick=()=>extract(file,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);m.appendChild(p);
}
function seekTo(video,t){return new Promise((res)=>{
let done=false;
const ok=()=>{if(done)return;done=true;video.removeEventListener('seeked',ok);res();};
video.addEventListener('seeked',ok);
try{video.currentTime=t;}catch(e){ok();return;}
setTimeout(ok,8000); // 容错:个别 seek 不触发就跳过
});}
const toBlob=(canvas,q)=>new Promise(r=>canvas.toBlob(b=>r(b),'image/jpeg',q));
function toGray(d){const a=d.data,n=a.length>>2,g=new Uint8Array(n);for(let i=0,j=0;j<n;i+=4,j++)g[j]=(a[i]*299+a[i+1]*587+a[i+2]*114)/1000|0;return g;}
function mae(a,b){const n=Math.min(a.length,b.length);if(!n)return 0;let s=0;for(let i=0;i<n;i++)s+=Math.abs(a[i]-b[i]);return Math.round(s/n/255*10000)/100;}
async function extract(file,interval){
if(!(interval>=0.2&&interval<=600)){alert('间隔需在 0.2~600 秒');return;}
// 进度屏
const m=$('#main');m.innerHTML='';
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>正在处理…</h2><p id="exname">${file.name}</p><div class="progress"><i id="pbar"></i></div><div class="pmsg" id="pmsg">读取视频…</div>`;
p.appendChild(b);m.appendChild(p);
const setP=(frac,msg)=>{const bar=$('#pbar'),t=$('#pmsg');if(bar)bar.style.width=Math.round(frac*100)+'%';if(t)t.textContent=msg;};
const video=document.createElement('video');
video.muted=true;video.playsInline=true;video.preload='auto';
const url=URL.createObjectURL(file);video.src=url;
try{
await new Promise((res,rej)=>{video.onloadedmetadata=()=>res();video.onerror=()=>rej(new Error('浏览器无法读取该视频(格式/编码可能不支持,建议用 mp4/H.264 或 webm'));});
const dur=video.duration,W=video.videoWidth,H=video.videoHeight;
if(!isFinite(dur)||dur<=0)throw new Error('无法获取视频时长');
if(!W||!H)throw new Error('无法获取视频画面尺寸');
const pw=Math.min(1280,W),ph=Math.round(H*Math.min(1280,W)/W);
const tw=Math.min(320,W),th=Math.round(H*Math.min(320,W)/W);
const dw=256,dh=Math.max(1,Math.round(H*256/W));
const pc=el('canvas');pc.width=pw;pc.height=ph;const pctx=pc.getContext('2d');
const tc=el('canvas');tc.width=tw;tc.height=th;const tctx=tc.getContext('2d');
const dc=el('canvas');dc.width=dw;dc.height=dh;const dctx=dc.getContext('2d',{willReadFrequently:true});
const id=genId(),total=Math.max(1,Math.ceil(dur/interval)),frames=[];
let prev=null;const t0=performance.now();
for(let i=0;i<total;i++){
const t=Math.min(i*interval,Math.max(0,dur-0.04));
await seekTo(video,t);
pctx.drawImage(video,0,0,pw,ph);
tctx.drawImage(video,0,0,tw,th);
dctx.drawImage(video,0,0,dw,dh);
const g=toGray(dctx.getImageData(0,0,dw,dh));
const diff=prev?mae(prev,g):null;prev=g;
const idx=i+1;
const [pb,tb]=await Promise.all([toBlob(pc,0.82),toBlob(tc,0.72)]);
await idbPut('frames',{key:`${id}|${idx}|preview`,vid:id,blob:pb});
await idbPut('frames',{key:`${id}|${idx}|thumb`,vid:id,blob:tb});
frames.push({idx,t:Math.round(t*10)/10,diff,pw,ph});
const done=i+1,elapsed=(performance.now()-t0)/1000,eta=done?elapsed/done*(total-done):0;
setP(done/total,`抽帧 ${done}/${total} · 剩约 ${fmtEta(eta)}`);
}
const meta={id,name:file.name,size:file.size,duration:dur,interval,frames,threshold:10,overrides:{},createdAt:Date.now()};
await idbPut('videos',meta);
URL.revokeObjectURL(url);
pendingFile=null;
await loadVideos();await openVideo(id);
}catch(e){
URL.revokeObjectURL(url);
const box=$('.box');if(box){box.innerHTML=`<h2>处理失败</h2><p class="err">${e.message||e}</p>`;const again=el('button');again.textContent='重新选择';again.onclick=()=>$('#fileInput').click();box.appendChild(again);}
}
}
// ====================== 保留判定 / toolbar ======================
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;}
let saveTimer=null;
function persistState(immediate){ // 把 threshold/overrides 存回 IDB
if(!cur)return;cur.threshold=threshold;cur.overrides=overrides;
clearTimeout(saveTimer);
if(immediate)idbPut('videos',cur).catch(()=>{}); // 离散动作(点选/重置)立即存
else saveTimer=setTimeout(()=>idbPut('videos',cur).catch(()=>{}),400); // 拖阈值防抖
}
function toggleFrame(f){
const next=!keptOf(f);
if(next===autoKeep(f))delete overrides[f.idx];else overrides[f.idx]=next;
updateStats();persistState(true);
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);
const 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;sl.style.width='200px';
const val=el('span','thval');val.textContent=threshold;
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();persistState();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 gz=el('div','grp');gz.innerHTML=`<label>缩略图</label>`;
const zs=el('input');zs.type='range';zs.min='110';zs.max='420';zs.step='10';zs.value=thumbW;zs.style.width='120px';zs.title='缩放缩略图';
zs.oninput=()=>{thumbW=+zs.value;localStorage.setItem('v2s_thumbw',thumbW);applyThumbW();};
gz.append(zs);
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();persistState(true);onlyKept?renderGrid():applyClasses();};
const ist=el('span','stat');ist.textContent=`间隔 ${cur.interval}s`;
g4.append(exp,rs,ist);
const g5=el('div','grp pager');g5.id='pager';
t.append(g1,g2,gz,g3,g4,g5);
setTimeout(()=>{updateStats();renderPager();},0);
return t;
}
function updateStats(){
const total=cur.frames.length,kept=cur.frames.filter(keptOf).length,man=Object.keys(overrides).length;
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total}`+(man?` <span style="opacity:.7">· 手动 ${man}</span>`:'');
}
// ====================== 网格 ======================
function revokeGridUrls(){gridUrls.forEach(u=>URL.revokeObjectURL(u));gridUrls=[];}
function renderGrid(){
const c=$('#content');if(!c)return;revokeGridUrls();c.innerHTML='';
const list=shown(),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.alt='';img.loading='lazy';
idbGet('frames',`${curId}|${f.idx}|thumb`).then(rec=>{if(rec&&rec.blob){const u=URL.createObjectURL(rec.blob);gridUrls.push(u);img.src=u;}}).catch(()=>{});
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(),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)));
}
function applyClasses(){document.querySelectorAll('.card').forEach(card=>{const f=cur.frames.find(x=>x.idx===+card.dataset.idx);if(f)paintCard(card,f);});}
// ====================== 悬停大图 ======================
const pv=$('#preview'),pvImg=$('#previewImg'),pvCap=$('#previewCap');
let hoverIdx=null,hoverUrl=null;
function clearHoverUrl(){if(hoverUrl){URL.revokeObjectURL(hoverUrl);hoverUrl=null;}}
function bindHover(card,f){
card.onmouseenter=async()=>{
hoverIdx=f.idx;
pvCap.textContent=`${fmtT(f.t)} · Δ${f.diff===null||f.diff===undefined?'起始帧':f.diff}`;
const rec=await idbGet('frames',`${curId}|${f.idx}|preview`).catch(()=>null);
if(!rec||hoverIdx!==f.idx)return;
clearHoverUrl();hoverUrl=URL.createObjectURL(rec.blob);
pvImg.onload=()=>{if(hoverIdx!==f.idx)return;positionPreview(card);pv.style.display='block';};
pvImg.src=hoverUrl;
};
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';clearHoverUrl();};
}
function positionPreview(card){
const r=card.getBoundingClientRect(),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';
}
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
// ====================== PDF 导出(客户端 jsPDF======================
let exporting=false;
const blobToDataURL=b=>new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=()=>rej(r.error);r.readAsDataURL(b);});
async function exportPdf(){
if(exporting)return;
const list=cur.frames.filter(keptOf);
if(!list.length){alert('没有要导出的帧 —— 调低阈值或手动保留几帧再试');return;}
exporting=true;
const mask=$('#expmask'),bar=$('#ebar'),msg=$('#emsg');
bar.style.width='0%';msg.textContent=`生成 PDF${list.length} 帧)…`;mask.classList.add('on');
try{
const { jsPDF }=window.jspdf;let doc=null;
for(let i=0;i<list.length;i++){
const f=list[i];
const rec=await idbGet('frames',`${curId}|${f.idx}|preview`);
if(!rec||!rec.blob)continue;
const durl=await blobToDataURL(rec.blob); // base64,不解码像素
const w=f.pw||1280,h=f.ph||720,o=w>=h?'l':'p';
if(!doc)doc=new jsPDF({orientation:o,unit:'px',format:[w,h],compress:true});
else doc.addPage([w,h],o);
doc.addImage(durl,'JPEG',0,0,w,h);
bar.style.width=Math.round((i+1)/list.length*100)+'%';msg.textContent=`生成 PDF ${i+1}/${list.length}`;
await new Promise(r=>setTimeout(r)); // 让出事件循环,避免卡 UI
}
if(!doc)throw new Error('没有可用的帧');
doc.save((cur.name||'slides').replace(/\.[^.]+$/,'')+'.pdf');
msg.textContent='完成,开始下载';await new Promise(r=>setTimeout(r,400));
}catch(e){alert(e.message||'导出失败');}
finally{exporting=false;mask.classList.remove('on');}
}
// ====================== 启动 ======================
(async()=>{
try{await loadVideos();}catch(e){console.error(e);}
render();
})();
</script>
</body>
</html>