video2slides: 加 PDF 导出(服务端流式)+ 点击切换帧 保留/弃用
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
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 头取宽高分量数
This commit is contained in:
@@ -52,13 +52,21 @@
|
||||
|
||||
/* 网格 */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
||||
.card{background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s,filter .15s;cursor:pointer}
|
||||
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000}
|
||||
.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:.4;filter:grayscale(1)}
|
||||
.card.dim:hover{opacity:.85;filter:grayscale(0)}
|
||||
.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}
|
||||
@@ -86,6 +94,10 @@
|
||||
.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}
|
||||
@@ -111,6 +123,11 @@
|
||||
|
||||
<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;};
|
||||
@@ -138,6 +155,7 @@ 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;
|
||||
|
||||
// ====================== 侧栏 ======================
|
||||
@@ -166,7 +184,7 @@ function renderSidebar(){
|
||||
// ====================== 打开视频 / 路由渲染 ======================
|
||||
async function openVideo(vid){
|
||||
stopPoll();
|
||||
curId=vid;page=0;
|
||||
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')){
|
||||
@@ -223,9 +241,30 @@ function progressPane(){
|
||||
}
|
||||
|
||||
// ====================== Toolbar + 网格 ======================
|
||||
function keptOf(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
|
||||
// 阈值给的是默认判定;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');
|
||||
// 阈值
|
||||
@@ -242,9 +281,12 @@ function buildToolbar(){
|
||||
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';
|
||||
g4.innerHTML=`<span class="stat">间隔 ${cur.interval}s</span>`;
|
||||
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);
|
||||
// 分页
|
||||
@@ -257,7 +299,8 @@ function buildToolbar(){
|
||||
function updateStats(){
|
||||
const total=cur.frames.length;
|
||||
const kept=cur.frames.filter(keptOf).length;
|
||||
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total} 帧`;
|
||||
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(){
|
||||
@@ -268,12 +311,15 @@ function renderGrid(){
|
||||
const slice=list.slice(page*pageSize,(page+1)*pageSize);
|
||||
const grid=el('div','grid');
|
||||
for(const f of slice){
|
||||
const card=el('div','card'+(keptOf(f)?' keep':' dim'));
|
||||
card.dataset.idx=f.idx;
|
||||
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(img,cap);
|
||||
card.append(pin,img,cap);
|
||||
paintCard(card,f);
|
||||
card.onclick=()=>toggleFrame(f);
|
||||
bindHover(card,f);
|
||||
grid.appendChild(card);
|
||||
}
|
||||
@@ -298,7 +344,7 @@ function renderPager(){
|
||||
function applyClasses(){
|
||||
document.querySelectorAll('.card').forEach(card=>{
|
||||
const idx=+card.dataset.idx;const f=cur.frames.find(x=>x.idx===idx);if(!f)return;
|
||||
const k=keptOf(f);card.classList.toggle('keep',k);card.classList.toggle('dim',!k);
|
||||
paintCard(card,f);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,6 +424,38 @@ function startPoll(){
|
||||
}
|
||||
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';});
|
||||
|
||||
// ====================== 启动 ======================
|
||||
|
||||
Reference in New Issue
Block a user